fix(ivy): i18n - ensure that colons in i18n metadata are not rendered (#33820)

The `:` char is used as a metadata marker in `$localize` messages.
If this char appears in the metadata it must be escaped, as `\:`.
Previously, although the `:` char was being escaped, the TS AST
being generated was not correct and so it was being output double
escaped, which meant that it appeared in the rendered message.

As of TS 3.6.2 the "raw" string can be specified when creating tagged
template AST nodes, so it is possible to correct this.

PR Close #33820
This commit is contained in:
Pete Bacon Darwin 2019-11-15 16:25:32 +00:00 committed by Alex Rickabaugh
parent 74e6d379d8
commit 62f7d0fe5c
7 changed files with 44 additions and 27 deletions

View File

@ -1,4 +1,4 @@
<h1 i18n> Hello {{ title }}! </h1> <h1 i18n="some:description"> Hello {{ title }}! </h1>
<p id="locale">{{ locale }}</p> <p id="locale">{{ locale }}</p>
<p id="pipe">{{ 1 | percent }} awesome</p> <p id="pipe">{{ 1 | percent }} awesome</p>
<p id="date">{{ jan | date : 'LLLL' }}</p> <p id="date">{{ jan | date : 'LLLL' }}</p>

View File

@ -1,6 +1,6 @@
<!--The content below is only a placeholder and can be replaced.--> <!--The content below is only a placeholder and can be replaced.-->
<div style="text-align:center"> <div style="text-align:center">
<h1 i18n> <h1 i18n="some:description">
Hello {{ title }}! Hello {{ title }}!
</h1> </h1>

View File

@ -549,9 +549,9 @@ function visitLocalizedString(ast: LocalizedString, context: Context, visitor: E
let template: ts.TemplateLiteral; let template: ts.TemplateLiteral;
const metaBlock = serializeI18nHead(ast.metaBlock, ast.messageParts[0]); const metaBlock = serializeI18nHead(ast.metaBlock, ast.messageParts[0]);
if (ast.messageParts.length === 1) { if (ast.messageParts.length === 1) {
template = ts.createNoSubstitutionTemplateLiteral(metaBlock); template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw);
} else { } else {
const head = ts.createTemplateHead(metaBlock); 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++) { for (let i = 1; i < ast.messageParts.length; i++) {
const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context); const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context);

View File

@ -264,7 +264,7 @@ describe('i18n support in the template compiler', () => {
$I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$; $I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$;
} }
else { 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$]; const $_c25$ = ["title", $I18N_23$];

View File

@ -364,7 +364,7 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex
visitLocalizedString(ast: o.LocalizedString, ctx: EmitterVisitorContext): any { visitLocalizedString(ast: o.LocalizedString, ctx: EmitterVisitorContext): any {
const head = serializeI18nHead(ast.metaBlock, ast.messageParts[0]); const head = serializeI18nHead(ast.metaBlock, ast.messageParts[0]);
ctx.print(ast, '$localize `' + escapeBackticks(head)); ctx.print(ast, '$localize `' + escapeBackticks(head.raw));
for (let i = 1; i < ast.messageParts.length; i++) { for (let i = 1; i < ast.messageParts.length; i++) {
ctx.print(ast, '${'); ctx.print(ast, '${');
ast.expressions[i - 1].visitExpression(this, ctx); ast.expressions[i - 1].visitExpression(this, ctx);

View File

@ -235,13 +235,15 @@ export function parseI18nMeta(meta?: string): I18nMeta {
} }
/** /**
* Serialize the given `meta` and `messagePart` a string that can be used in a `$localize` * Serialize the given `meta` and `messagePart` "cooked" and "raw" strings that can be used in a
* tagged string. The format of the metadata is the same as that parsed by `parseI18nMeta()`. * `$localize` tagged string. The format of the metadata is the same as that parsed by
* `parseI18nMeta()`.
* *
* @param meta The metadata to serialize * @param meta The metadata to serialize
* @param messagePart The first part of the tagged string * @param messagePart The first part of the tagged string
*/ */
export function serializeI18nHead(meta: I18nMeta, messagePart: string): string { export function serializeI18nHead(
meta: I18nMeta, messagePart: string): {cooked: string, raw: string} {
let metaBlock = meta.description || ''; let metaBlock = meta.description || '';
if (meta.meaning) { if (meta.meaning) {
metaBlock = `${meta.meaning}|${metaBlock}`; metaBlock = `${meta.meaning}|${metaBlock}`;
@ -251,9 +253,12 @@ export function serializeI18nHead(meta: I18nMeta, messagePart: string): string {
} }
if (metaBlock === '') { if (metaBlock === '') {
// There is no metaBlock, so we must ensure that any starting colon is escaped. // There is no metaBlock, so we must ensure that any starting colon is escaped.
return escapeStartingColon(messagePart); return {cooked: messagePart, raw: escapeStartingColon(messagePart)};
} else { } else {
return `:${escapeColons(metaBlock)}:${messagePart}`; return {
cooked: `:${metaBlock}:${messagePart}`,
raw: `:${escapeColons(metaBlock)}:${messagePart}`
};
} }
} }

View File

@ -210,26 +210,38 @@ describe('Utils', () => {
}); });
it('serializeI18nHead()', () => { it('serializeI18nHead()', () => {
expect(serializeI18nHead(meta(), '')).toEqual(''); expect(serializeI18nHead(meta(), '')).toEqual({cooked: '', raw: ''});
expect(serializeI18nHead(meta('', '', 'desc'), '')).toEqual(':desc:'); expect(serializeI18nHead(meta('', '', 'desc'), ''))
expect(serializeI18nHead(meta('id', '', 'desc'), '')).toEqual(':desc@@id:'); .toEqual({cooked: ':desc:', raw: ':desc:'});
expect(serializeI18nHead(meta('', 'meaning', 'desc'), '')).toEqual(':meaning|desc:'); expect(serializeI18nHead(meta('id', '', 'desc'), ''))
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), '')).toEqual(':meaning|desc@@id:'); .toEqual({cooked: ':desc@@id:', raw: ':desc@@id:'});
expect(serializeI18nHead(meta('id', '', ''), '')).toEqual(':@@id:'); expect(serializeI18nHead(meta('', 'meaning', 'desc'), ''))
.toEqual({cooked: ':meaning|desc:', raw: ':meaning|desc:'});
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), ''))
.toEqual({cooked: ':meaning|desc@@id:', raw: ':meaning|desc@@id:'});
expect(serializeI18nHead(meta('id', '', ''), '')).toEqual({cooked: ':@@id:', raw: ':@@id:'});
// Escaping colons (block markers) // Escaping colons (block markers)
expect(serializeI18nHead(meta('id:sub_id', 'meaning', 'desc'), '')) expect(serializeI18nHead(meta('id:sub_id', 'meaning', 'desc'), ''))
.toEqual(':meaning|desc@@id\\:sub_id:'); .toEqual({cooked: ':meaning|desc@@id:sub_id:', raw: ':meaning|desc@@id\\:sub_id:'});
expect(serializeI18nHead(meta('id', 'meaning:sub_meaning', 'desc'), '')) expect(serializeI18nHead(meta('id', 'meaning:sub_meaning', 'desc'), '')).toEqual({
.toEqual(':meaning\\:sub_meaning|desc@@id:'); cooked: ':meaning:sub_meaning|desc@@id:',
raw: ':meaning\\:sub_meaning|desc@@id:'
});
expect(serializeI18nHead(meta('id', 'meaning', 'desc:sub_desc'), '')) expect(serializeI18nHead(meta('id', 'meaning', 'desc:sub_desc'), ''))
.toEqual(':meaning|desc\\:sub_desc@@id:'); .toEqual({cooked: ':meaning|desc:sub_desc@@id:', raw: ':meaning|desc\\:sub_desc@@id:'});
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), 'message source')) expect(serializeI18nHead(meta('id', 'meaning', 'desc'), 'message source')).toEqual({
.toEqual(':meaning|desc@@id:message source'); cooked: ':meaning|desc@@id:message source',
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), ':message source')) raw: ':meaning|desc@@id:message source'
.toEqual(':meaning|desc@@id::message source'); });
expect(serializeI18nHead(meta('', '', ''), 'message source')).toEqual('message source'); expect(serializeI18nHead(meta('id', 'meaning', 'desc'), ':message source')).toEqual({
expect(serializeI18nHead(meta('', '', ''), ':message source')).toEqual('\\:message source'); cooked: ':meaning|desc@@id::message source',
raw: ':meaning|desc@@id::message source'
});
expect(serializeI18nHead(meta('', '', ''), 'message source'))
.toEqual({cooked: 'message source', raw: 'message source'});
expect(serializeI18nHead(meta('', '', ''), ':message source'))
.toEqual({cooked: ':message source', raw: '\\:message source'});
}); });
it('serializeI18nPlaceholderBlock()', () => { it('serializeI18nPlaceholderBlock()', () => {