From b32f4fccfeb0bd71e30f10aea9c9fd4f928a8ee7 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 31 Aug 2020 16:19:11 +0100 Subject: [PATCH 1/4] refactor(compiler): capture interpolation source-spans in expression parser The expression parser will split the expression up at the interpolation markers into expressions and static strings. This commit also captures the positions of these strings in the expression to be used in source-mapping later. --- packages/compiler/src/expression_parser/parser.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index f71fa40f5741e..090e236135184 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -14,7 +14,10 @@ import {AbsoluteSourceSpan, AST, AstVisitor, ASTWithSource, Binary, BindingPipe, import {EOF, isIdentifier, isQuote, Lexer, Token, TokenType} from './lexer'; export class SplitInterpolation { - constructor(public strings: string[], public expressions: string[], public offsets: number[]) {} + constructor( + public strings: string[], public stringSpans: {start: number, end: number}[], + public expressions: string[], public expressionsSpans: {start: number, end: number}[], + public offsets: number[]) {} } export class TemplateBindingParseResult { @@ -194,18 +197,24 @@ export class Parser { const strings: string[] = []; const expressions: string[] = []; const offsets: number[] = []; + const stringSpans: {start: number, end: number}[] = []; + const expressionSpans: {start: number, end: number}[] = []; let offset = 0; for (let i = 0; i < parts.length; i++) { const part: string = parts[i]; if (i % 2 === 0) { // fixed string strings.push(part); + const start = offset; offset += part.length; + stringSpans.push({start, end: offset}); } else if (part.trim().length > 0) { + const start = offset; offset += interpolationConfig.start.length; expressions.push(part); offsets.push(offset); offset += part.length + interpolationConfig.end.length; + expressionSpans.push({start, end: offset}); } else { this._reportError( 'Blank expressions are not allowed in interpolated strings', input, @@ -213,9 +222,10 @@ export class Parser { location); expressions.push('$implicit'); offsets.push(offset); + expressionSpans.push({start: offset, end: offset}); } } - return new SplitInterpolation(strings, expressions, offsets); + return new SplitInterpolation(strings, stringSpans, expressions, expressionSpans, offsets); } wrapLiteralPrimitive(input: string|null, location: any, absoluteOffset: number): ASTWithSource { From b5ae01426043e3ce03b4ab8708c7a2ddbccbaa38 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 31 Aug 2020 16:23:01 +0100 Subject: [PATCH 2/4] refactor(compiler): track the closing source-span of TagPlaceholders The `TagPlaceholder` can contain children, in which case there are two source spans of interest: the opening tag and the closing tag. This commit now allows the closing tag source-span to be tracked, so that it can be used later in source-mapping. --- packages/compiler/src/i18n/i18n_ast.ts | 5 +++-- packages/compiler/src/i18n/i18n_parser.ts | 19 +++++++++++++++---- packages/compiler/src/i18n/message_bundle.ts | 3 ++- .../test/i18n/serializers/i18n_ast_spec.ts | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/i18n/i18n_ast.ts b/packages/compiler/src/i18n/i18n_ast.ts index 0a7159bbc185c..0f7c412205bb0 100644 --- a/packages/compiler/src/i18n/i18n_ast.ts +++ b/packages/compiler/src/i18n/i18n_ast.ts @@ -87,7 +87,7 @@ export class TagPlaceholder implements Node { constructor( public tag: string, public attrs: {[k: string]: string}, public startName: string, public closeName: string, public children: Node[], public isVoid: boolean, - public sourceSpan: ParseSourceSpan) {} + public sourceSpan: ParseSourceSpan, public closeSourceSpan: ParseSourceSpan|null) {} visit(visitor: Visitor, context?: any): any { return visitor.visitTagPlaceholder(this, context); @@ -151,7 +151,8 @@ export class CloneVisitor implements Visitor { visitTagPlaceholder(ph: TagPlaceholder, context?: any): TagPlaceholder { const children = ph.children.map(n => n.visit(this, context)); return new TagPlaceholder( - ph.tag, ph.attrs, ph.startName, ph.closeName, children, ph.isVoid, ph.sourceSpan); + ph.tag, ph.attrs, ph.startName, ph.closeName, children, ph.isVoid, ph.sourceSpan, + ph.closeSourceSpan); } visitPlaceholder(ph: Placeholder, context?: any): Placeholder { diff --git a/packages/compiler/src/i18n/i18n_parser.ts b/packages/compiler/src/i18n/i18n_parser.ts index 1a1f7f35bd179..923a9d8dff628 100644 --- a/packages/compiler/src/i18n/i18n_parser.ts +++ b/packages/compiler/src/i18n/i18n_parser.ts @@ -93,7 +93,8 @@ class _I18nVisitor implements html.Visitor { } const node = new i18n.TagPlaceholder( - el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan!); + el.name, attrs, startPhName, closePhName, children, isVoid, el.startSourceSpan!, + el.endSourceSpan); return context.visitNodeFn(el, node); } @@ -167,22 +168,32 @@ class _I18nVisitor implements html.Visitor { if (splitInterpolation.strings[i].length) { // No need to add empty strings - nodes.push(new i18n.Text(splitInterpolation.strings[i], sourceSpan)); + const stringSpan = getOffsetSourceSpan(sourceSpan, splitInterpolation.stringSpans[i]); + nodes.push(new i18n.Text(splitInterpolation.strings[i], stringSpan)); } - nodes.push(new i18n.Placeholder(expression, phName, sourceSpan)); + const expressionSpan = + getOffsetSourceSpan(sourceSpan, splitInterpolation.expressionsSpans[i]); + nodes.push(new i18n.Placeholder(expression, phName, expressionSpan)); context.placeholderToContent[phName] = sDelimiter + expression + eDelimiter; } // The last index contains no expression const lastStringIdx = splitInterpolation.strings.length - 1; if (splitInterpolation.strings[lastStringIdx].length) { - nodes.push(new i18n.Text(splitInterpolation.strings[lastStringIdx], sourceSpan)); + const stringSpan = + getOffsetSourceSpan(sourceSpan, splitInterpolation.stringSpans[lastStringIdx]); + nodes.push(new i18n.Text(splitInterpolation.strings[lastStringIdx], stringSpan)); } return container; } } +function getOffsetSourceSpan( + sourceSpan: ParseSourceSpan, {start, end}: {start: number, end: number}): ParseSourceSpan { + return new ParseSourceSpan(sourceSpan.start.moveBy(start), sourceSpan.start.moveBy(end)); +} + const _CUSTOM_PH_EXP = /\/\/[\s\S]*i18n[\s\S]*\([\s\S]*ph[\s\S]*=[\s\S]*("|')([\s\S]*?)\1[\s\S]*\)/g; diff --git a/packages/compiler/src/i18n/message_bundle.ts b/packages/compiler/src/i18n/message_bundle.ts index 37fb284604d53..036b6d45fb175 100644 --- a/packages/compiler/src/i18n/message_bundle.ts +++ b/packages/compiler/src/i18n/message_bundle.ts @@ -94,7 +94,8 @@ class MapPlaceholderNames extends i18n.CloneVisitor { const closeName = ph.closeName ? mapper.toPublicName(ph.closeName)! : ph.closeName; const children = ph.children.map(n => n.visit(this, mapper)); return new i18n.TagPlaceholder( - ph.tag, ph.attrs, startName, closeName, children, ph.isVoid, ph.sourceSpan); + ph.tag, ph.attrs, startName, closeName, children, ph.isVoid, ph.sourceSpan, + ph.closeSourceSpan); } visitPlaceholder(ph: i18n.Placeholder, mapper: PlaceholderMapper): i18n.Placeholder { diff --git a/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts b/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts index 3ee5872d416f7..910bb735334f2 100644 --- a/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts +++ b/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts @@ -41,7 +41,7 @@ import {_extractMessages} from '../i18n_parser_spec'; new i18n.IcuPlaceholder(null!, '', null!), ], null!); - const tag = new i18n.TagPlaceholder('', {}, '', '', [container], false, null!); + const tag = new i18n.TagPlaceholder('', {}, '', '', [container], false, null!, null); const icu = new i18n.Icu('', '', {tag}, null!); icu.visit(visitor); From c90f765450b62ff145e8c385af8b245367a09d29 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 31 Aug 2020 16:27:04 +0100 Subject: [PATCH 3/4] refactor(compiler): move the MessagePiece classes into output_ast.ts The `MessagePiece` and derived classes, `LiteralPiece` and `PlaceholderPiece` need to be referenced in the `LocalizedString` output AST class, so that we can render the source-spans of each piece. --- packages/compiler/src/output/output_ast.ts | 27 ++- .../src/render3/view/i18n/localize_utils.ts | 77 ++++---- .../compiler/test/output/js_emitter_spec.ts | 9 +- .../compiler/test/output/ts_emitter_spec.ts | 9 +- .../compiler/test/render3/view/i18n_spec.ts | 164 +++++++++++++----- 5 files changed, 191 insertions(+), 95 deletions(-) diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index f718565ce32f4..c0e06637f0533 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -9,7 +9,6 @@ import {ParseSourceSpan} from '../parse_util'; import {I18nMeta} from '../render3/view/i18n/meta'; -import {error} from '../util'; //// Types export enum TypeModifier { @@ -516,11 +515,16 @@ export class LiteralExpr extends Expression { } } +export abstract class MessagePiece { + constructor(public text: string, public sourceSpan: ParseSourceSpan) {} +} +export class LiteralPiece extends MessagePiece {} +export class PlaceholderPiece extends MessagePiece {} export class LocalizedString extends Expression { constructor( - readonly metaBlock: I18nMeta, readonly messageParts: string[], - readonly placeHolderNames: string[], readonly expressions: Expression[], + readonly metaBlock: I18nMeta, readonly messageParts: LiteralPiece[], + readonly placeHolderNames: PlaceholderPiece[], readonly expressions: Expression[], sourceSpan?: ParseSourceSpan|null) { super(STRING_TYPE, sourceSpan); } @@ -563,7 +567,16 @@ export class LocalizedString extends Expression { metaBlock = `${metaBlock}${LEGACY_ID_INDICATOR}${legacyId}`; }); } - return createCookedRawString(metaBlock, this.messageParts[0]); + return createCookedRawString(metaBlock, this.messageParts[0].text); + } + + getMessagePartSourceSpan(i: number): ParseSourceSpan|null { + return this.messageParts[i]?.sourceSpan ?? this.sourceSpan; + } + + getPlaceholderSourceSpan(i: number): ParseSourceSpan { + return this.placeHolderNames[i]?.sourceSpan ?? this.expressions[i]?.sourceSpan ?? + this.sourceSpan; } /** @@ -574,9 +587,9 @@ export class LocalizedString extends Expression { * @param messagePart The following message string after this placeholder */ serializeI18nTemplatePart(partIndex: number): {cooked: string, raw: string} { - const placeholderName = this.placeHolderNames[partIndex - 1]; + const placeholderName = this.placeHolderNames[partIndex - 1].text; const messagePart = this.messageParts[partIndex]; - return createCookedRawString(placeholderName, messagePart); + return createCookedRawString(placeholderName, messagePart.text); } } @@ -1799,7 +1812,7 @@ export function literal( } export function localizedString( - metaBlock: I18nMeta, messageParts: string[], placeholderNames: string[], + metaBlock: I18nMeta, messageParts: LiteralPiece[], placeholderNames: PlaceholderPiece[], expressions: Expression[], sourceSpan?: ParseSourceSpan|null): LocalizedString { return new LocalizedString(metaBlock, messageParts, placeholderNames, expressions, sourceSpan); } diff --git a/packages/compiler/src/render3/view/i18n/localize_utils.ts b/packages/compiler/src/render3/view/i18n/localize_utils.ts index 127a3f6d94699..4c4f29eff27db 100644 --- a/packages/compiler/src/render3/view/i18n/localize_utils.ts +++ b/packages/compiler/src/render3/view/i18n/localize_utils.ts @@ -7,7 +7,7 @@ */ import * as i18n from '../../../i18n/i18n_ast'; import * as o from '../../../output/output_ast'; -import {ParseSourceSpan} from '../../../parse_util'; +import {ParseLocation, ParseSourceSpan} from '../../../parse_util'; import {serializeIcuNode} from './icu_serializer'; import {formatI18nPlaceholderName} from './util'; @@ -17,60 +17,55 @@ export function createLocalizeStatements( params: {[name: string]: o.Expression}): o.Statement[] { const {messageParts, placeHolders} = serializeI18nMessageForLocalize(message); const sourceSpan = getSourceSpan(message); - const expressions = placeHolders.map(ph => params[ph]); + const expressions = placeHolders.map(ph => params[ph.text]); const localizedString = o.localizedString(message, messageParts, placeHolders, expressions, sourceSpan); const variableInitialization = variable.set(localizedString); return [new o.ExpressionStatement(variableInitialization)]; } -class MessagePiece { - constructor(public text: string) {} -} -class LiteralPiece extends MessagePiece {} -class PlaceholderPiece extends MessagePiece { - constructor(name: string) { - super(formatI18nPlaceholderName(name, /* useCamelCase */ false)); - } -} - /** * This visitor walks over an i18n tree, capturing literal strings and placeholders. * * The result can be used for generating the `$localize` tagged template literals. */ class LocalizeSerializerVisitor implements i18n.Visitor { - visitText(text: i18n.Text, context: MessagePiece[]): any { - if (context[context.length - 1] instanceof LiteralPiece) { + visitText(text: i18n.Text, context: o.MessagePiece[]): any { + if (context[context.length - 1] instanceof o.LiteralPiece) { // Two literal pieces in a row means that there was some comment node in-between. context[context.length - 1].text += text.value; } else { - context.push(new LiteralPiece(text.value)); + context.push(new o.LiteralPiece(text.value, text.sourceSpan)); } } - visitContainer(container: i18n.Container, context: MessagePiece[]): any { + visitContainer(container: i18n.Container, context: o.MessagePiece[]): any { container.children.forEach(child => child.visit(this, context)); } - visitIcu(icu: i18n.Icu, context: MessagePiece[]): any { - context.push(new LiteralPiece(serializeIcuNode(icu))); + visitIcu(icu: i18n.Icu, context: o.MessagePiece[]): any { + context.push(new o.LiteralPiece(serializeIcuNode(icu), icu.sourceSpan)); } - visitTagPlaceholder(ph: i18n.TagPlaceholder, context: MessagePiece[]): any { - context.push(new PlaceholderPiece(ph.startName)); + visitTagPlaceholder(ph: i18n.TagPlaceholder, context: o.MessagePiece[]): any { + context.push(this.createPlaceholderPiece(ph.startName, ph.sourceSpan)); if (!ph.isVoid) { ph.children.forEach(child => child.visit(this, context)); - context.push(new PlaceholderPiece(ph.closeName)); + context.push(this.createPlaceholderPiece(ph.closeName, ph.closeSourceSpan ?? ph.sourceSpan)); } } - visitPlaceholder(ph: i18n.Placeholder, context: MessagePiece[]): any { - context.push(new PlaceholderPiece(ph.name)); + visitPlaceholder(ph: i18n.Placeholder, context: o.MessagePiece[]): any { + context.push(this.createPlaceholderPiece(ph.name, ph.sourceSpan)); } visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { - context.push(new PlaceholderPiece(ph.name)); + context.push(this.createPlaceholderPiece(ph.name, ph.sourceSpan)); + } + + private createPlaceholderPiece(name: string, sourceSpan: ParseSourceSpan): o.PlaceholderPiece { + return new o.PlaceholderPiece( + formatI18nPlaceholderName(name, /* useCamelCase */ false), sourceSpan); } } @@ -85,8 +80,8 @@ const serializerVisitor = new LocalizeSerializerVisitor(); * @returns an object containing the messageParts and placeholders. */ export function serializeI18nMessageForLocalize(message: i18n.Message): - {messageParts: string[], placeHolders: string[]} { - const pieces: MessagePiece[] = []; + {messageParts: o.LiteralPiece[], placeHolders: o.PlaceholderPiece[]} { + const pieces: o.MessagePiece[] = []; message.nodes.forEach(node => node.visit(serializerVisitor, pieces)); return processMessagePieces(pieces); } @@ -107,31 +102,35 @@ function getSourceSpan(message: i18n.Message): ParseSourceSpan { * @param pieces The pieces to process. * @returns an object containing the messageParts and placeholders. */ -function processMessagePieces(pieces: MessagePiece[]): - {messageParts: string[], placeHolders: string[]} { - const messageParts: string[] = []; - const placeHolders: string[] = []; +function processMessagePieces(pieces: o.MessagePiece[]): + {messageParts: o.LiteralPiece[], placeHolders: o.PlaceholderPiece[]} { + const messageParts: o.LiteralPiece[] = []; + const placeHolders: o.PlaceholderPiece[] = []; - if (pieces[0] instanceof PlaceholderPiece) { + if (pieces[0] instanceof o.PlaceholderPiece) { // The first piece was a placeholder so we need to add an initial empty message part. - messageParts.push(''); + messageParts.push(createEmptyMessagePart(pieces[0].sourceSpan.start)); } for (let i = 0; i < pieces.length; i++) { const part = pieces[i]; - if (part instanceof LiteralPiece) { - messageParts.push(part.text); + if (part instanceof o.LiteralPiece) { + messageParts.push(part); } else { - placeHolders.push(part.text); - if (pieces[i - 1] instanceof PlaceholderPiece) { + placeHolders.push(part); + if (pieces[i - 1] instanceof o.PlaceholderPiece) { // There were two placeholders in a row, so we need to add an empty message part. - messageParts.push(''); + messageParts.push(createEmptyMessagePart(part.sourceSpan.end)); } } } - if (pieces[pieces.length - 1] instanceof PlaceholderPiece) { + if (pieces[pieces.length - 1] instanceof o.PlaceholderPiece) { // The last piece was a placeholder so we need to add a final empty message part. - messageParts.push(''); + messageParts.push(createEmptyMessagePart(pieces[pieces.length - 1].sourceSpan.end)); } return {messageParts, placeHolders}; } + +function createEmptyMessagePart(location: ParseLocation): o.LiteralPiece { + return new o.LiteralPiece('', new ParseSourceSpan(location, location)); +} diff --git a/packages/compiler/test/output/js_emitter_spec.ts b/packages/compiler/test/output/js_emitter_spec.ts index c21297c4ee823..b6ff7deab5a81 100644 --- a/packages/compiler/test/output/js_emitter_spec.ts +++ b/packages/compiler/test/output/js_emitter_spec.ts @@ -205,9 +205,12 @@ const externalModuleIdentifier = new o.ExternalReference(anotherModuleUrl, 'some }); it('should support ES5 localized strings', () => { - expect(emitStmt(new o.ExpressionStatement(o.localizedString( - {}, ['ab\\:c', 'd"e\'f'], ['ph1'], - [o.literal(7, o.NUMBER_TYPE).plus(o.literal(8, o.NUMBER_TYPE))])))) + const messageParts = + [new o.LiteralPiece('ab\\:c', {} as any), new o.LiteralPiece('d"e\'f', {} as any)]; + const placeholders = [new o.PlaceholderPiece('ph1', {} as any)]; + const expressions = [o.literal(7, o.NUMBER_TYPE).plus(o.literal(8, o.NUMBER_TYPE))]; + const localizedString = o.localizedString({}, messageParts, placeholders, expressions); + expect(emitStmt(new o.ExpressionStatement(localizedString))) .toEqual( String.raw `$localize((this&&this.__makeTemplateObject||function(e,t){return Object.defineProperty?Object.defineProperty(e,"raw",{value:t}):e.raw=t,e})(['ab\\:c', ':ph1:d"e\'f'], ['ab\\\\:c', ':ph1:d"e\'f']), (7 + 8));`); diff --git a/packages/compiler/test/output/ts_emitter_spec.ts b/packages/compiler/test/output/ts_emitter_spec.ts index 78da4b697fb9c..e20b136f38b90 100644 --- a/packages/compiler/test/output/ts_emitter_spec.ts +++ b/packages/compiler/test/output/ts_emitter_spec.ts @@ -254,9 +254,12 @@ const externalModuleIdentifier = new o.ExternalReference(anotherModuleUrl, 'some }); it('should support localized strings', () => { - expect(emitStmt(new o.ExpressionStatement(o.localizedString( - {}, ['ab\\:c', 'd"e\'f'], ['ph1'], - [o.literal(7, o.NUMBER_TYPE).plus(o.literal(8, o.NUMBER_TYPE))])))) + const messageParts = + [new o.LiteralPiece('ab\\:c', {} as any), new o.LiteralPiece('d"e\'f', {} as any)]; + const placeholders = [new o.PlaceholderPiece('ph1', {} as any)]; + const expressions = [o.literal(7, o.NUMBER_TYPE).plus(o.literal(8, o.NUMBER_TYPE))]; + const localizedString = o.localizedString({}, messageParts, placeholders, expressions); + expect(emitStmt(new o.ExpressionStatement(localizedString))) .toEqual('$localize `ab\\\\:c${(7 + 8)}:ph1:d"e\'f`;'); }); diff --git a/packages/compiler/test/render3/view/i18n_spec.ts b/packages/compiler/test/render3/view/i18n_spec.ts index 328cb7b7f3d32..50d534c64ea83 100644 --- a/packages/compiler/test/render3/view/i18n_spec.ts +++ b/packages/compiler/test/render3/view/i18n_spec.ts @@ -10,6 +10,7 @@ import {Lexer} from '../../../src/expression_parser/lexer'; import {Parser} from '../../../src/expression_parser/parser'; import * as i18n from '../../../src/i18n/i18n_ast'; import * as o from '../../../src/output/output_ast'; +import {ParseSourceSpan} from '../../../src/parse_util'; import * as t from '../../../src/render3/r3_ast'; import {I18nContext} from '../../../src/render3/view/i18n/context'; import {serializeI18nMessageForGetMsg} from '../../../src/render3/view/i18n/get_msg_utils'; @@ -221,64 +222,75 @@ describe('Utils', () => { }); it('serializeI18nHead()', () => { - expect(o.localizedString(meta(), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta(), [literal('')], [], []).serializeI18nHead()) .toEqual({cooked: '', raw: ''}); - expect(o.localizedString(meta('', '', 'desc'), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta('', '', 'desc'), [literal('')], [], []).serializeI18nHead()) .toEqual({cooked: ':desc:', raw: ':desc:'}); - expect(o.localizedString(meta('id', '', 'desc'), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta('id', '', 'desc'), [literal('')], [], []).serializeI18nHead()) .toEqual({cooked: ':desc@@id:', raw: ':desc@@id:'}); - expect(o.localizedString(meta('', 'meaning', 'desc'), [''], [], []).serializeI18nHead()) + expect( + o.localizedString(meta('', 'meaning', 'desc'), [literal('')], [], []).serializeI18nHead()) .toEqual({cooked: ':meaning|desc:', raw: ':meaning|desc:'}); - expect(o.localizedString(meta('id', 'meaning', 'desc'), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta('id', 'meaning', 'desc'), [literal('')], [], []) + .serializeI18nHead()) .toEqual({cooked: ':meaning|desc@@id:', raw: ':meaning|desc@@id:'}); - expect(o.localizedString(meta('id', '', ''), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta('id', '', ''), [literal('')], [], []).serializeI18nHead()) .toEqual({cooked: ':@@id:', raw: ':@@id:'}); // Escaping colons (block markers) - expect( - o.localizedString(meta('id:sub_id', 'meaning', 'desc'), [''], [], []).serializeI18nHead()) + expect(o.localizedString(meta('id:sub_id', 'meaning', 'desc'), [literal('')], [], []) + .serializeI18nHead()) .toEqual({cooked: ':meaning|desc@@id:sub_id:', raw: ':meaning|desc@@id\\:sub_id:'}); - expect(o.localizedString(meta('id', 'meaning:sub_meaning', 'desc'), [''], [], []) + expect(o.localizedString(meta('id', 'meaning:sub_meaning', 'desc'), [literal('')], [], []) .serializeI18nHead()) .toEqual( {cooked: ':meaning:sub_meaning|desc@@id:', raw: ':meaning\\:sub_meaning|desc@@id:'}); - expect(o.localizedString(meta('id', 'meaning', 'desc:sub_desc'), [''], [], []) + expect(o.localizedString(meta('id', 'meaning', 'desc:sub_desc'), [literal('')], [], []) .serializeI18nHead()) .toEqual({cooked: ':meaning|desc:sub_desc@@id:', raw: ':meaning|desc\\:sub_desc@@id:'}); - expect(o.localizedString(meta('id', 'meaning', 'desc'), ['message source'], [], []) + expect(o.localizedString(meta('id', 'meaning', 'desc'), [literal('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'], [], []) + expect(o.localizedString(meta('id', 'meaning', 'desc'), [literal(':message source')], [], []) .serializeI18nHead()) .toEqual({ cooked: ':meaning|desc@@id::message source', raw: ':meaning|desc@@id::message source' }); - expect(o.localizedString(meta('', '', ''), ['message source'], [], []).serializeI18nHead()) + expect(o.localizedString(meta('', '', ''), [literal('message source')], [], []) + .serializeI18nHead()) .toEqual({cooked: 'message source', raw: 'message source'}); - expect(o.localizedString(meta('', '', ''), [':message source'], [], []).serializeI18nHead()) + expect(o.localizedString(meta('', '', ''), [literal(':message source')], [], []) + .serializeI18nHead()) .toEqual({cooked: ':message source', raw: '\\:message source'}); }); it('serializeI18nPlaceholderBlock()', () => { - expect(o.localizedString(meta('', '', ''), ['', ''], [''], []).serializeI18nTemplatePart(1)) + expect(o.localizedString(meta('', '', ''), [literal(''), literal('')], [literal('')], []) + .serializeI18nTemplatePart(1)) .toEqual({cooked: '', raw: ''}); - expect( - o.localizedString(meta('', '', ''), ['', ''], ['abc'], []).serializeI18nTemplatePart(1)) - .toEqual({cooked: ':abc:', raw: ':abc:'}); - expect(o.localizedString(meta('', '', ''), ['', 'message'], [''], []) + expect(o.localizedString( + meta('', '', ''), [literal(''), literal('')], + [new o.LiteralPiece('abc', {} as any)], []) .serializeI18nTemplatePart(1)) + .toEqual({cooked: ':abc:', raw: ':abc:'}); + expect( + o.localizedString(meta('', '', ''), [literal(''), literal('message')], [literal('')], []) + .serializeI18nTemplatePart(1)) .toEqual({cooked: 'message', raw: 'message'}); - expect(o.localizedString(meta('', '', ''), ['', 'message'], ['abc'], []) + expect(o.localizedString( + meta('', '', ''), [literal(''), literal('message')], [literal('abc')], []) .serializeI18nTemplatePart(1)) .toEqual({cooked: ':abc:message', raw: ':abc:message'}); - expect(o.localizedString(meta('', '', ''), ['', ':message'], [''], []) - .serializeI18nTemplatePart(1)) + expect( + o.localizedString(meta('', '', ''), [literal(''), literal(':message')], [literal('')], []) + .serializeI18nTemplatePart(1)) .toEqual({cooked: ':message', raw: '\\:message'}); - expect(o.localizedString(meta('', '', ''), ['', ':message'], ['abc'], []) + expect(o.localizedString( + meta('', '', ''), [literal(''), literal(':message')], [literal('abc')], []) .serializeI18nTemplatePart(1)) .toEqual({cooked: ':abc::message', raw: ':abc::message'}); }); @@ -349,55 +361,106 @@ describe('serializeI18nMessageForLocalize', () => { }; it('should serialize plain text for `$localize()`', () => { - expect(serialize('Some text')).toEqual({messageParts: ['Some text'], placeHolders: []}); + expect(serialize('Some text')) + .toEqual({messageParts: [literal('Some text')], placeHolders: []}); }); it('should serialize text with interpolation for `$localize()`', () => { expect(serialize('Some text {{ valueA }} and {{ valueB + valueC }} done')).toEqual({ - messageParts: ['Some text ', ' and ', ' done'], - placeHolders: ['INTERPOLATION', 'INTERPOLATION_1'] + messageParts: [literal('Some text '), literal(' and '), literal(' done')], + placeHolders: [placeholder('INTERPOLATION'), placeholder('INTERPOLATION_1')], }); }); + it('should compute source-spans when serializing text with interpolation for `$localize()`', + () => { + const {messageParts, placeHolders} = + serialize('Some text {{ valueA }} and {{ valueB + valueC }} done'); + + expect(messageParts[0].text).toEqual('Some text '); + expect(messageParts[0].sourceSpan.toString()).toEqual('Some text '); + expect(messageParts[1].text).toEqual(' and '); + expect(messageParts[1].sourceSpan.toString()).toEqual(' and '); + expect(messageParts[2].text).toEqual(' done'); + expect(messageParts[2].sourceSpan.toString()).toEqual(' done'); + + expect(placeHolders[0].text).toEqual('INTERPOLATION'); + expect(placeHolders[0].sourceSpan.toString()).toEqual('{{ valueA }}'); + expect(placeHolders[1].text).toEqual('INTERPOLATION_1'); + expect(placeHolders[1].sourceSpan.toString()).toEqual('{{ valueB + valueC }}'); + }); + it('should serialize text with interpolation at start for `$localize()`', () => { expect(serialize('{{ valueA }} and {{ valueB + valueC }} done')).toEqual({ - messageParts: ['', ' and ', ' done'], - placeHolders: ['INTERPOLATION', 'INTERPOLATION_1'] + messageParts: [literal(''), literal(' and '), literal(' done')], + placeHolders: [placeholder('INTERPOLATION'), placeholder('INTERPOLATION_1')], }); }); it('should serialize text with interpolation at end for `$localize()`', () => { expect(serialize('Some text {{ valueA }} and {{ valueB + valueC }}')).toEqual({ - messageParts: ['Some text ', ' and ', ''], - placeHolders: ['INTERPOLATION', 'INTERPOLATION_1'] + messageParts: [literal('Some text '), literal(' and '), literal('')], + placeHolders: [placeholder('INTERPOLATION'), placeholder('INTERPOLATION_1')], }); }); it('should serialize only interpolation for `$localize()`', () => { - expect(serialize('{{ valueB + valueC }}')) - .toEqual({messageParts: ['', ''], placeHolders: ['INTERPOLATION']}); + expect(serialize('{{ valueB + valueC }}')).toEqual({ + messageParts: [literal(''), literal('')], + placeHolders: [placeholder('INTERPOLATION')] + }); }); it('should serialize interpolation with named placeholder for `$localize()`', () => { - expect(serialize('{{ valueB + valueC // i18n(ph="PLACEHOLDER NAME") }}')) - .toEqual({messageParts: ['', ''], placeHolders: ['PLACEHOLDER_NAME']}); + expect(serialize('{{ valueB + valueC // i18n(ph="PLACEHOLDER NAME") }}')).toEqual({ + messageParts: [literal(''), literal('')], + placeHolders: [placeholder('PLACEHOLDER_NAME')] + }); }); it('should serialize content with HTML tags for `$localize()`', () => { expect(serialize('A B
C
D')).toEqual({ - messageParts: ['A ', 'B', 'C', '', ' D'], - placeHolders: ['START_TAG_SPAN', 'START_TAG_DIV', 'CLOSE_TAG_DIV', 'CLOSE_TAG_SPAN'] + messageParts: [literal('A '), literal('B'), literal('C'), literal(''), literal(' D')], + placeHolders: [ + placeholder('START_TAG_SPAN'), placeholder('START_TAG_DIV'), placeholder('CLOSE_TAG_DIV'), + placeholder('CLOSE_TAG_SPAN') + ] }); }); + it('should compute source-spans when serializing content with HTML tags for `$localize()`', + () => { + const {messageParts, placeHolders} = serialize('A B
C
D'); + + expect(messageParts[0].text).toEqual('A '); + expect(messageParts[0].sourceSpan.toString()).toEqual('A '); + expect(messageParts[1].text).toEqual('B'); + expect(messageParts[1].sourceSpan.toString()).toEqual('B'); + expect(messageParts[2].text).toEqual('C'); + expect(messageParts[2].sourceSpan.toString()).toEqual('C'); + expect(messageParts[3].text).toEqual(''); + expect(messageParts[3].sourceSpan.toString()).toEqual(''); + expect(messageParts[4].text).toEqual(' D'); + expect(messageParts[4].sourceSpan.toString()).toEqual(' D'); + + expect(placeHolders[0].text).toEqual('START_TAG_SPAN'); + expect(placeHolders[0].sourceSpan.toString()).toEqual(''); + expect(placeHolders[1].text).toEqual('START_TAG_DIV'); + expect(placeHolders[1].sourceSpan.toString()).toEqual('
'); + expect(placeHolders[2].text).toEqual('CLOSE_TAG_DIV'); + expect(placeHolders[2].sourceSpan.toString()).toEqual('
'); + expect(placeHolders[3].text).toEqual('CLOSE_TAG_SPAN'); + expect(placeHolders[3].sourceSpan.toString()).toEqual('
'); + }); + it('should serialize simple ICU for `$localize()`', () => { expect(serialize('{age, plural, 10 {ten} other {other}}')).toEqual({ - messageParts: ['{VAR_PLURAL, plural, 10 {ten} other {other}}'], + messageParts: [literal('{VAR_PLURAL, plural, 10 {ten} other {other}}')], placeHolders: [] }); }); @@ -408,7 +471,8 @@ describe('serializeI18nMessageForLocalize', () => { '{age, plural, 10 {ten {size, select, 1 {one} 2 {two} other {2+}}} other {other}}')) .toEqual({ messageParts: [ - '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {one} 2 {two} other {2+}}} other {other}}' + literal( + '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {one} 2 {two} other {2+}}} other {other}}') ], placeHolders: [] }); @@ -418,7 +482,8 @@ describe('serializeI18nMessageForLocalize', () => { it('should serialize ICU with embedded HTML for `$localize()`', () => { expect(serialize('{age, plural, 10 {ten} other {
other
}}')).toEqual({ messageParts: [ - '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}other{CLOSE_TAG_DIV}}}' + literal( + '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}other{CLOSE_TAG_DIV}}}') ], placeHolders: [] }); @@ -427,7 +492,8 @@ describe('serializeI18nMessageForLocalize', () => { it('should serialize ICU with embedded interpolation for `$localize()`', () => { expect(serialize('{age, plural, 10 {ten} other {{{age}} years old}}')).toEqual({ messageParts: [ - '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{INTERPOLATION} years old}}' + literal( + '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{INTERPOLATION} years old}}') ], placeHolders: [] }); @@ -438,8 +504,11 @@ describe('serializeI18nMessageForLocalize', () => { serialize( '{gender, select, male {male} female {female} other {other}}
{gender, select, male {male} female {female} other {other}}
')) .toEqual({ - messageParts: ['', '', '', '', ''], - placeHolders: ['ICU', 'START_TAG_DIV', 'ICU', 'CLOSE_TAG_DIV'] + messageParts: [literal(''), literal(''), literal(''), literal(''), literal('')], + placeHolders: [ + placeholder('ICU'), placeholder('START_TAG_DIV'), placeholder('ICU'), + placeholder('CLOSE_TAG_DIV') + ] }); }); @@ -449,7 +518,8 @@ describe('serializeI18nMessageForLocalize', () => { '{age, plural, 10 {ten {size, select, 1 {{{ varOne }}} 2 {{{ varTwo }}} other {2+}}} other {other}}')) .toEqual({ messageParts: [ - '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {{INTERPOLATION}} 2 {{INTERPOLATION_1}} other {2+}}} other {other}}' + literal( + '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {{INTERPOLATION}} 2 {{INTERPOLATION_1}} other {2+}}} other {other}}') ], placeHolders: [] }); @@ -486,3 +556,11 @@ describe('serializeIcuNode', () => { .toEqual('{VAR_SELECT, select, 10 {ten} other {{INTERPOLATION} years old}}'); }); }); + +function literal(text: string, span: any = jasmine.any(ParseSourceSpan)): o.LiteralPiece { + return new o.LiteralPiece(text, span); +} + +function placeholder(name: string, span: any = jasmine.any(ParseSourceSpan)): o.PlaceholderPiece { + return new o.PlaceholderPiece(name, span); +} From 0c0f1440c562a20b6ad729b277f559ff882f5e29 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 31 Aug 2020 16:27:44 +0100 Subject: [PATCH 4/4] fix(compiler-cli): compute source-mappings for localized strings Previously, localized strings had very limited or incorrect source-mapping information available. Now the i18n AST nodes and related output AST nodes include source-span information about message-parts and placeholders - including closing tag placeholders. This information is then used when generating the final localized string ASTs to ensure that the correct source-mapping is rendered. See #38588 (comment) --- .../src/ngtsc/translator/src/translator.ts | 225 ++++++++++-------- .../test/ngtsc/template_mapping_spec.ts | 87 ++++++- packages/compiler/src/i18n/i18n_ast.ts | 6 +- packages/compiler/src/i18n/i18n_parser.ts | 4 +- packages/compiler/src/i18n/message_bundle.ts | 2 +- .../src/render3/view/i18n/localize_utils.ts | 4 +- .../test/i18n/serializers/i18n_ast_spec.ts | 2 +- 7 files changed, 217 insertions(+), 113 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index 195cae9f67009..6a24078d3b186 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ArrayType, AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinType, BuiltinTypeName, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, Statement, StatementVisitor, StmtModifier, ThrowStmt, TryCatchStmt, Type, TypeofExpr, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler'; +import {ArrayType, AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinType, BuiltinTypeName, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ParseSourceSpan, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, Statement, StatementVisitor, StmtModifier, ThrowStmt, TryCatchStmt, Type, TypeofExpr, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler'; import {LocalizedString, UnaryOperator, UnaryOperatorExpr} from '@angular/compiler/src/output/output_ast'; import * as ts from 'typescript'; @@ -212,7 +212,7 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor visitReadVarExpr(ast: ReadVarExpr, context: Context): ts.Identifier { const identifier = ts.createIdentifier(ast.name!); - this.setSourceMapRange(identifier, ast); + this.setSourceMapRange(identifier, ast.sourceSpan); return identifier; } @@ -244,7 +244,7 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor const call = ts.createCall( ast.name !== null ? ts.createPropertyAccess(target, ast.name) : target, undefined, ast.args.map(arg => arg.visitExpression(this, context))); - this.setSourceMapRange(call, ast); + this.setSourceMapRange(call, ast.sourceSpan); return call; } @@ -255,7 +255,7 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor if (ast.pure) { ts.addSyntheticLeadingComment(expr, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false); } - this.setSourceMapRange(expr, ast); + this.setSourceMapRange(expr, ast.sourceSpan); return expr; } @@ -274,15 +274,15 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor } else { expr = ts.createLiteral(ast.value); } - this.setSourceMapRange(expr, ast); + this.setSourceMapRange(expr, ast.sourceSpan); return expr; } visitLocalizedString(ast: LocalizedString, context: Context): ts.Expression { const localizedString = this.scriptTarget >= ts.ScriptTarget.ES2015 ? - createLocalizedStringTaggedTemplate(ast, context, this) : - createLocalizedStringFunctionCall(ast, context, this, this.imports); - this.setSourceMapRange(localizedString, ast); + this.createLocalizedStringTaggedTemplate(ast, context) : + this.createLocalizedStringFunctionCall(ast, context); + this.setSourceMapRange(localizedString, ast.sourceSpan); return localizedString; } @@ -395,7 +395,7 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor visitLiteralArrayExpr(ast: LiteralArrayExpr, context: Context): ts.ArrayLiteralExpression { const expr = ts.createArrayLiteral(ast.entries.map(expr => expr.visitExpression(this, context))); - this.setSourceMapRange(expr, ast); + this.setSourceMapRange(expr, ast.sourceSpan); return expr; } @@ -405,7 +405,7 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor entry.quoted ? ts.createLiteral(entry.key) : ts.createIdentifier(entry.key), entry.value.visitExpression(this, context))); const expr = ts.createObjectLiteral(entries); - this.setSourceMapRange(expr, ast); + this.setSourceMapRange(expr, ast.sourceSpan); return expr; } @@ -424,9 +424,111 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor return ts.createTypeOf(ast.expr.visitExpression(this, context)); } - private setSourceMapRange(expr: ts.Expression, ast: Expression) { - if (ast.sourceSpan) { - const {start, end} = ast.sourceSpan; + /** + * Translate the `LocalizedString` node into a `TaggedTemplateExpression` for ES2015 formatted + * output. + */ + private createLocalizedStringTaggedTemplate(ast: LocalizedString, context: Context): + ts.TaggedTemplateExpression { + let template: ts.TemplateLiteral; + const length = ast.messageParts.length; + const metaBlock = ast.serializeI18nHead(); + if (length === 1) { + template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw); + this.setSourceMapRange(template, ast.getMessagePartSourceSpan(0)); + } else { + // Create the head part + const head = ts.createTemplateHead(metaBlock.cooked, metaBlock.raw); + this.setSourceMapRange(head, ast.getMessagePartSourceSpan(0)); + const spans: ts.TemplateSpan[] = []; + // Create the middle parts + for (let i = 1; i < length - 1; i++) { + const resolvedExpression = ast.expressions[i - 1].visitExpression(this, context); + this.setSourceMapRange(resolvedExpression, ast.getPlaceholderSourceSpan(i - 1)); + const templatePart = ast.serializeI18nTemplatePart(i); + const templateMiddle = createTemplateMiddle(templatePart.cooked, templatePart.raw); + this.setSourceMapRange(templateMiddle, ast.getMessagePartSourceSpan(i)); + const templateSpan = ts.createTemplateSpan(resolvedExpression, templateMiddle); + spans.push(templateSpan); + } + // Create the tail part + const resolvedExpression = ast.expressions[length - 2].visitExpression(this, context); + this.setSourceMapRange(resolvedExpression, ast.getPlaceholderSourceSpan(length - 2)); + const templatePart = ast.serializeI18nTemplatePart(length - 1); + const templateTail = createTemplateTail(templatePart.cooked, templatePart.raw); + this.setSourceMapRange(templateTail, ast.getMessagePartSourceSpan(length - 1)); + spans.push(ts.createTemplateSpan(resolvedExpression, templateTail)); + // Put it all together + template = ts.createTemplateExpression(head, spans); + } + const expression = ts.createTaggedTemplate(ts.createIdentifier('$localize'), template); + this.setSourceMapRange(expression, ast.sourceSpan); + return expression; + } + + /** + * Translate the `LocalizedString` node into a `$localize` call using the imported + * `__makeTemplateObject` helper for ES5 formatted output. + */ + private createLocalizedStringFunctionCall(ast: LocalizedString, context: Context) { + // A `$localize` message consists `messageParts` and `expressions`, which get interleaved + // together. The interleaved pieces look like: + // `[messagePart0, expression0, messagePart1, expression1, messagePart2]` + // + // Note that there is always a message part at the start and end, and so therefore + // `messageParts.length === expressions.length + 1`. + // + // Each message part may be prefixed with "metadata", which is wrapped in colons (:) delimiters. + // The metadata is attached to the first and subsequent message parts by calls to + // `serializeI18nHead()` and `serializeI18nTemplatePart()` respectively. + + // The first message part (i.e. `ast.messageParts[0]`) is used to initialize `messageParts` + // array. + const messageParts = [ast.serializeI18nHead()]; + const expressions: any[] = []; + + // The rest of the `ast.messageParts` and each of the expressions are `ast.expressions` pushed + // into the arrays. Note that `ast.messagePart[i]` corresponds to `expressions[i-1]` + for (let i = 1; i < ast.messageParts.length; i++) { + expressions.push(ast.expressions[i - 1].visitExpression(this, context)); + messageParts.push(ast.serializeI18nTemplatePart(i)); + } + + // The resulting downlevelled tagged template string uses a call to the `__makeTemplateObject()` + // helper, so we must ensure it has been imported. + const {moduleImport, symbol} = + this.imports.generateNamedImport('tslib', '__makeTemplateObject'); + const __makeTemplateObjectHelper = (moduleImport === null) ? + ts.createIdentifier(symbol) : + ts.createPropertyAccess(ts.createIdentifier(moduleImport), ts.createIdentifier(symbol)); + + // Generate the call in the form: + // `$localize(__makeTemplateObject(cookedMessageParts, rawMessageParts), ...expressions);` + const cookedLiterals = messageParts.map( + (messagePart, i) => + this.createLiteral(messagePart.cooked, ast.getMessagePartSourceSpan(i))); + const rawLiterals = messageParts.map( + (messagePart, i) => this.createLiteral(messagePart.raw, ast.getMessagePartSourceSpan(i))); + return ts.createCall( + /* expression */ ts.createIdentifier('$localize'), + /* typeArguments */ undefined, + /* argumentsArray */[ + ts.createCall( + /* expression */ __makeTemplateObjectHelper, + /* typeArguments */ undefined, + /* argumentsArray */ + [ + ts.createArrayLiteral(cookedLiterals), + ts.createArrayLiteral(rawLiterals), + ]), + ...expressions, + ]); + } + + + private setSourceMapRange(expr: ts.Node, sourceSpan: ParseSourceSpan|null) { + if (sourceSpan) { + const {start, end} = sourceSpan; const {url, content} = start.file; if (url) { if (!this.externalSourceFiles.has(url)) { @@ -437,6 +539,12 @@ class ExpressionTranslatorVisitor implements ExpressionVisitor, StatementVisitor } } } + + private createLiteral(text: string, span: ParseSourceSpan|null) { + const literal = ts.createStringLiteral(text); + this.setSourceMapRange(literal, span); + return literal; + } } export class TypeTranslatorVisitor implements ExpressionVisitor, TypeVisitor { @@ -662,40 +770,6 @@ export class TypeTranslatorVisitor implements ExpressionVisitor, TypeVisitor { } } -/** - * Translate the `LocalizedString` node into a `TaggedTemplateExpression` for ES2015 formatted - * output. - */ -function createLocalizedStringTaggedTemplate( - ast: LocalizedString, context: Context, visitor: ExpressionVisitor) { - let template: ts.TemplateLiteral; - const length = ast.messageParts.length; - const metaBlock = ast.serializeI18nHead(); - if (length === 1) { - template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw); - } else { - // Create the head part - const head = ts.createTemplateHead(metaBlock.cooked, metaBlock.raw); - const spans: ts.TemplateSpan[] = []; - // Create the middle parts - for (let i = 1; i < length - 1; i++) { - const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context); - const templatePart = ast.serializeI18nTemplatePart(i); - const templateMiddle = createTemplateMiddle(templatePart.cooked, templatePart.raw); - spans.push(ts.createTemplateSpan(resolvedExpression, templateMiddle)); - } - // Create the tail part - const resolvedExpression = ast.expressions[length - 2].visitExpression(visitor, context); - 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); - } - 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 { @@ -710,59 +784,4 @@ function createTemplateTail(cooked: string, raw: string): ts.TemplateTail { const node: ts.TemplateLiteralLikeNode = ts.createTemplateHead(cooked, raw); (node.kind as ts.SyntaxKind) = ts.SyntaxKind.TemplateTail; return node as ts.TemplateTail; -} - -/** - * Translate the `LocalizedString` node into a `$localize` call using the imported - * `__makeTemplateObject` helper for ES5 formatted output. - */ -function createLocalizedStringFunctionCall( - ast: LocalizedString, context: Context, visitor: ExpressionVisitor, imports: ImportManager) { - // A `$localize` message consists `messageParts` and `expressions`, which get interleaved - // together. The interleaved pieces look like: - // `[messagePart0, expression0, messagePart1, expression1, messagePart2]` - // - // Note that there is always a message part at the start and end, and so therefore - // `messageParts.length === expressions.length + 1`. - // - // Each message part may be prefixed with "metadata", which is wrapped in colons (:) delimiters. - // The metadata is attached to the first and subsequent message parts by calls to - // `serializeI18nHead()` and `serializeI18nTemplatePart()` respectively. - - // The first message part (i.e. `ast.messageParts[0]`) is used to initialize `messageParts` array. - const messageParts = [ast.serializeI18nHead()]; - const expressions: any[] = []; - - // The rest of the `ast.messageParts` and each of the expressions are `ast.expressions` pushed - // into the arrays. Note that `ast.messagePart[i]` corresponds to `expressions[i-1]` - for (let i = 1; i < ast.messageParts.length; i++) { - expressions.push(ast.expressions[i - 1].visitExpression(visitor, context)); - messageParts.push(ast.serializeI18nTemplatePart(i)); - } - - // The resulting downlevelled tagged template string uses a call to the `__makeTemplateObject()` - // helper, so we must ensure it has been imported. - const {moduleImport, symbol} = imports.generateNamedImport('tslib', '__makeTemplateObject'); - const __makeTemplateObjectHelper = (moduleImport === null) ? - ts.createIdentifier(symbol) : - ts.createPropertyAccess(ts.createIdentifier(moduleImport), ts.createIdentifier(symbol)); - - // Generate the call in the form: - // `$localize(__makeTemplateObject(cookedMessageParts, rawMessageParts), ...expressions);` - return ts.createCall( - /* expression */ ts.createIdentifier('$localize'), - /* typeArguments */ undefined, - /* argumentsArray */[ - ts.createCall( - /* expression */ __makeTemplateObjectHelper, - /* typeArguments */ undefined, - /* argumentsArray */ - [ - ts.createArrayLiteral( - messageParts.map(messagePart => ts.createStringLiteral(messagePart.cooked))), - ts.createArrayLiteral( - messageParts.map(messagePart => ts.createStringLiteral(messagePart.raw))), - ]), - ...expressions, - ]); -} +} \ No newline at end of file diff --git a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts index c6865f8efac6f..b93026c79e6b0 100644 --- a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts @@ -24,7 +24,7 @@ runInEachFileSystem((os) => { beforeEach(() => { env = NgtscTestEnvironment.setup(testFiles); - env.tsconfig(); + env.tsconfig({sourceMap: true, target: 'es2015', enableI18nLegacyMessageIdFormat: false}); }); describe('Inline templates', () => { @@ -360,6 +360,90 @@ runInEachFileSystem((os) => { }); }); + describe('$localize', () => { + it('should create simple i18n message source-mapping', () => { + const mappings = compileAndMap(`
Hello, World!
`); + expect(mappings).toContain({ + source: '
', + generated: 'i0.ɵɵelementStart(0, "div")', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: 'Hello, World!', + generated: '`Hello, World!`', + sourceUrl: '../test.ts', + }); + }); + + it('should create placeholder source-mappings', () => { + const mappings = compileAndMap(`
Hello, {{name}}!
`); + expect(mappings).toContain({ + source: '
', + generated: 'i0.ɵɵelementStart(0, "div")', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '
', + generated: 'i0.ɵɵelementEnd()', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: 'Hello, ', + generated: '`Hello, ${', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '{{name}}', + generated: '"\\uFFFD0\\uFFFD"', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '!', + generated: '}:INTERPOLATION:!`', + sourceUrl: '../test.ts', + }); + }); + + it('should create tag (container) placeholder source-mappings', () => { + const mappings = compileAndMap(`
Hello, World!
`); + expect(mappings).toContain({ + source: '
', + generated: 'i0.ɵɵelementStart(0, "div")', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '
', + generated: 'i0.ɵɵelementEnd()', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: 'Hello, ', + generated: '`Hello, ${', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '', + generated: '"\\uFFFD#2\\uFFFD"', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: 'World', + generated: '}:START_BOLD_TEXT:World${', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '', + generated: '"\\uFFFD/#2\\uFFFD"', + sourceUrl: '../test.ts', + }); + expect(mappings).toContain({ + source: '!', + generated: '}:CLOSE_BOLD_TEXT:!`', + sourceUrl: '../test.ts', + }); + }); + }); + it('should create (simple string) inline template source-mapping', () => { const mappings = compileAndMap('
this is a test
{{ 1 + 2 }}
'); @@ -520,7 +604,6 @@ runInEachFileSystem((os) => { function compileAndMap(template: string, templateUrl: string|null = null) { const templateConfig = templateUrl ? `templateUrl: '${templateUrl}'` : ('template: `' + template.replace(/`/g, '\\`') + '`'); - env.tsconfig({sourceMap: true}); env.write('test.ts', ` import {Component} from '@angular/core'; diff --git a/packages/compiler/src/i18n/i18n_ast.ts b/packages/compiler/src/i18n/i18n_ast.ts index 0f7c412205bb0..9bd6259bd30cc 100644 --- a/packages/compiler/src/i18n/i18n_ast.ts +++ b/packages/compiler/src/i18n/i18n_ast.ts @@ -87,7 +87,9 @@ export class TagPlaceholder implements Node { constructor( public tag: string, public attrs: {[k: string]: string}, public startName: string, public closeName: string, public children: Node[], public isVoid: boolean, - public sourceSpan: ParseSourceSpan, public closeSourceSpan: ParseSourceSpan|null) {} + // TODO sourceSpan should cover all (we need a startSourceSpan and endSourceSpan) + public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan|null, + public endSourceSpan: ParseSourceSpan|null) {} visit(visitor: Visitor, context?: any): any { return visitor.visitTagPlaceholder(this, context); @@ -152,7 +154,7 @@ export class CloneVisitor implements Visitor { const children = ph.children.map(n => n.visit(this, context)); return new TagPlaceholder( ph.tag, ph.attrs, ph.startName, ph.closeName, children, ph.isVoid, ph.sourceSpan, - ph.closeSourceSpan); + ph.startSourceSpan, ph.endSourceSpan); } visitPlaceholder(ph: Placeholder, context?: any): Placeholder { diff --git a/packages/compiler/src/i18n/i18n_parser.ts b/packages/compiler/src/i18n/i18n_parser.ts index 923a9d8dff628..e8d4d7f54c80f 100644 --- a/packages/compiler/src/i18n/i18n_parser.ts +++ b/packages/compiler/src/i18n/i18n_parser.ts @@ -93,8 +93,8 @@ class _I18nVisitor implements html.Visitor { } const node = new i18n.TagPlaceholder( - el.name, attrs, startPhName, closePhName, children, isVoid, el.startSourceSpan!, - el.endSourceSpan); + el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan, + el.startSourceSpan, el.endSourceSpan); return context.visitNodeFn(el, node); } diff --git a/packages/compiler/src/i18n/message_bundle.ts b/packages/compiler/src/i18n/message_bundle.ts index 036b6d45fb175..b3fa5895384b8 100644 --- a/packages/compiler/src/i18n/message_bundle.ts +++ b/packages/compiler/src/i18n/message_bundle.ts @@ -95,7 +95,7 @@ class MapPlaceholderNames extends i18n.CloneVisitor { const children = ph.children.map(n => n.visit(this, mapper)); return new i18n.TagPlaceholder( ph.tag, ph.attrs, startName, closeName, children, ph.isVoid, ph.sourceSpan, - ph.closeSourceSpan); + ph.startSourceSpan, ph.endSourceSpan); } visitPlaceholder(ph: i18n.Placeholder, mapper: PlaceholderMapper): i18n.Placeholder { diff --git a/packages/compiler/src/render3/view/i18n/localize_utils.ts b/packages/compiler/src/render3/view/i18n/localize_utils.ts index 4c4f29eff27db..444139ecacb61 100644 --- a/packages/compiler/src/render3/view/i18n/localize_utils.ts +++ b/packages/compiler/src/render3/view/i18n/localize_utils.ts @@ -48,10 +48,10 @@ class LocalizeSerializerVisitor implements i18n.Visitor { } visitTagPlaceholder(ph: i18n.TagPlaceholder, context: o.MessagePiece[]): any { - context.push(this.createPlaceholderPiece(ph.startName, ph.sourceSpan)); + context.push(this.createPlaceholderPiece(ph.startName, ph.startSourceSpan ?? ph.sourceSpan)); if (!ph.isVoid) { ph.children.forEach(child => child.visit(this, context)); - context.push(this.createPlaceholderPiece(ph.closeName, ph.closeSourceSpan ?? ph.sourceSpan)); + context.push(this.createPlaceholderPiece(ph.closeName, ph.endSourceSpan ?? ph.sourceSpan)); } } diff --git a/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts b/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts index 910bb735334f2..7b2fe3b7b715e 100644 --- a/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts +++ b/packages/compiler/test/i18n/serializers/i18n_ast_spec.ts @@ -41,7 +41,7 @@ import {_extractMessages} from '../i18n_parser_spec'; new i18n.IcuPlaceholder(null!, '', null!), ], null!); - const tag = new i18n.TagPlaceholder('', {}, '', '', [container], false, null!, null); + const tag = new i18n.TagPlaceholder('', {}, '', '', [container], false, null!, null, null); const icu = new i18n.Icu('', '', {tag}, null!); icu.visit(visitor);