diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index 195cae9f67009..e0a43348bfae8 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,114 @@ 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(resolvedExpression, ast.getMessagePartSourceSpan(i)); + this.setSourceMapRange(templateMiddle, ast.getMessagePartSourceSpan(i)); + const templateSpan = ts.createTemplateSpan(resolvedExpression, templateMiddle); + // this.setSourceMapRange(templateSpan, ast.getMessagePartSourceSpan(i)); + 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 +542,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 +773,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 +787,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..51611dfc75f5e 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/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 { 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/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index f718565ce32f4..f73e65db32750 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 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/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); 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..4a02bf1e39834 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,107 @@ 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()`', + () => { + debugger; + 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 +472,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 +483,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 +493,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 +505,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 +519,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 +557,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); +}