diff --git a/modules/@angular/compiler/src/assertions.ts b/modules/@angular/compiler/src/assertions.ts index abacbea18a..8550426554 100644 --- a/modules/@angular/compiler/src/assertions.ts +++ b/modules/@angular/compiler/src/assertions.ts @@ -16,3 +16,24 @@ export function assertArrayOfStrings(identifier: string, value: any) { } } } + +const INTERPOLATION_BLACKLIST_REGEXPS = [ + /^\s*$/g, // empty + /[<>]/g, // html tag + /^[\{\}]$/g, // i18n expansion +]; + +export function assertInterpolationSymbols(identifier: string, value: any): void { + if (isDevMode() && !isBlank(value) && (!isArray(value) || value.length != 2)) { + throw new BaseException(`Expected '${identifier}' to be an array, [start, end].`); + } else if (isDevMode() && !isBlank(value)) { + const start = value[0] as string; + const end = value[1] as string; + // black list checking + INTERPOLATION_BLACKLIST_REGEXPS.forEach(regexp => { + if (regexp.test(start) || regexp.test(end)) { + throw new BaseException(`['${start}', '${end}'] contains unusable interpolation symbol.`); + } + }); + } +} diff --git a/modules/@angular/compiler/src/compile_metadata.ts b/modules/@angular/compiler/src/compile_metadata.ts index 389ab0aef9..e8e0627de0 100644 --- a/modules/@angular/compiler/src/compile_metadata.ts +++ b/modules/@angular/compiler/src/compile_metadata.ts @@ -603,15 +603,18 @@ export class CompileTemplateMetadata { styleUrls: string[]; animations: CompileAnimationEntryMetadata[]; ngContentSelectors: string[]; + interpolation: [string, string]; constructor( - {encapsulation, template, templateUrl, styles, styleUrls, animations, ngContentSelectors}: { + {encapsulation, template, templateUrl, styles, styleUrls, animations, ngContentSelectors, + interpolation}: { encapsulation?: ViewEncapsulation, template?: string, templateUrl?: string, styles?: string[], styleUrls?: string[], ngContentSelectors?: string[], - animations?: CompileAnimationEntryMetadata[] + animations?: CompileAnimationEntryMetadata[], + interpolation?: [string, string] } = {}) { this.encapsulation = encapsulation; this.template = template; @@ -620,6 +623,10 @@ export class CompileTemplateMetadata { this.styleUrls = isPresent(styleUrls) ? styleUrls : []; this.animations = isPresent(animations) ? ListWrapper.flatten(animations) : []; this.ngContentSelectors = isPresent(ngContentSelectors) ? ngContentSelectors : []; + if (isPresent(interpolation) && interpolation.length != 2) { + throw new BaseException(`'interpolation' should have a start and an end symbol.`); + } + this.interpolation = interpolation; } static fromJson(data: {[key: string]: any}): CompileTemplateMetadata { @@ -634,7 +641,8 @@ export class CompileTemplateMetadata { styles: data['styles'], styleUrls: data['styleUrls'], animations: animations, - ngContentSelectors: data['ngContentSelectors'] + ngContentSelectors: data['ngContentSelectors'], + interpolation: data['interpolation'] }); } @@ -647,7 +655,8 @@ export class CompileTemplateMetadata { 'styles': this.styles, 'styleUrls': this.styleUrls, 'animations': _objToJson(this.animations), - 'ngContentSelectors': this.ngContentSelectors + 'ngContentSelectors': this.ngContentSelectors, + 'interpolation': this.interpolation }; } } diff --git a/modules/@angular/compiler/src/directive_normalizer.ts b/modules/@angular/compiler/src/directive_normalizer.ts index 783f27ee24..2df0a9a6d2 100644 --- a/modules/@angular/compiler/src/directive_normalizer.ts +++ b/modules/@angular/compiler/src/directive_normalizer.ts @@ -104,7 +104,8 @@ export class DirectiveNormalizer { styles: allResolvedStyles, styleUrls: allStyleAbsUrls, ngContentSelectors: visitor.ngContentSelectors, - animations: templateMeta.animations + animations: templateMeta.animations, + interpolation: templateMeta.interpolation }); } } diff --git a/modules/@angular/compiler/src/expression_parser/parser.ts b/modules/@angular/compiler/src/expression_parser/parser.ts index 1c7205471f..a4834f2e99 100644 --- a/modules/@angular/compiler/src/expression_parser/parser.ts +++ b/modules/@angular/compiler/src/expression_parser/parser.ts @@ -2,15 +2,14 @@ import {Injectable} from '@angular/core'; import {ListWrapper} from '../facade/collection'; import {BaseException} from '../facade/exceptions'; -import {StringWrapper, isBlank, isPresent} from '../facade/lang'; +import {RegExpWrapper, StringWrapper, escapeRegExp, isBlank, isPresent} from '../facade/lang'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../interpolation_config'; import {AST, ASTWithSource, AstVisitor, Binary, BindingPipe, Chain, Conditional, EmptyExpr, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, PrefixNot, PropertyRead, PropertyWrite, Quote, SafeMethodCall, SafePropertyRead, TemplateBinding} from './ast'; import {$COLON, $COMMA, $LBRACE, $LBRACKET, $LPAREN, $PERIOD, $RBRACE, $RBRACKET, $RPAREN, $SEMICOLON, $SLASH, EOF, Lexer, Token, isIdentifier, isQuote} from './lexer'; var _implicitReceiver = new ImplicitReceiver(); -// TODO(tbosch): Cannot make this const/final right now because of the transpiler... -var INTERPOLATION_REGEXP = /\{\{([\s\S]*?)\}\}/g; class ParseException extends BaseException { constructor(message: string, input: string, errLocation: string, ctxLocation?: any) { @@ -26,25 +25,36 @@ export class TemplateBindingParseResult { constructor(public templateBindings: TemplateBinding[], public warnings: string[]) {} } +function _createInterpolateRegExp(config: InterpolationConfig): RegExp { + const regexp = escapeRegExp(config.start) + '([\\s\\S]*?)' + escapeRegExp(config.end); + return RegExpWrapper.create(regexp, 'g'); +} + @Injectable() export class Parser { constructor(/** @internal */ public _lexer: Lexer) {} - parseAction(input: string, location: any): ASTWithSource { - this._checkNoInterpolation(input, location); + parseAction( + input: string, location: any, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource { + this._checkNoInterpolation(input, location, interpolationConfig); var tokens = this._lexer.tokenize(this._stripComments(input)); var ast = new _ParseAST(input, location, tokens, true).parseChain(); return new ASTWithSource(ast, input, location); } - parseBinding(input: string, location: any): ASTWithSource { - var ast = this._parseBindingAst(input, location); + parseBinding( + input: string, location: any, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource { + var ast = this._parseBindingAst(input, location, interpolationConfig); return new ASTWithSource(ast, input, location); } - parseSimpleBinding(input: string, location: string): ASTWithSource { - var ast = this._parseBindingAst(input, location); + parseSimpleBinding( + input: string, location: string, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource { + var ast = this._parseBindingAst(input, location, interpolationConfig); if (!SimpleExpressionChecker.check(ast)) { throw new ParseException( 'Host binding expression can only contain field access and constants', input, location); @@ -52,7 +62,8 @@ export class Parser { return new ASTWithSource(ast, input, location); } - private _parseBindingAst(input: string, location: string): AST { + private _parseBindingAst( + input: string, location: string, interpolationConfig: InterpolationConfig): AST { // Quotes expressions use 3rd-party expression language. We don't want to use // our lexer or parser for that, so we check for that ahead of time. var quote = this._parseQuote(input, location); @@ -61,7 +72,7 @@ export class Parser { return quote; } - this._checkNoInterpolation(input, location); + this._checkNoInterpolation(input, location, interpolationConfig); var tokens = this._lexer.tokenize(this._stripComments(input)); return new _ParseAST(input, location, tokens, false).parseChain(); } @@ -81,8 +92,10 @@ export class Parser { return new _ParseAST(input, location, tokens, false).parseTemplateBindings(); } - parseInterpolation(input: string, location: any): ASTWithSource { - let split = this.splitInterpolation(input, location); + parseInterpolation( + input: string, location: any, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource { + let split = this.splitInterpolation(input, location, interpolationConfig); if (split == null) return null; let expressions: AST[] = []; @@ -96,8 +109,11 @@ export class Parser { return new ASTWithSource(new Interpolation(split.strings, expressions), input, location); } - splitInterpolation(input: string, location: string): SplitInterpolation { - var parts = StringWrapper.split(input, INTERPOLATION_REGEXP); + splitInterpolation( + input: string, location: string, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): SplitInterpolation { + const regexp = _createInterpolateRegExp(interpolationConfig); + const parts = StringWrapper.split(input, regexp); if (parts.length <= 1) { return null; } @@ -114,7 +130,8 @@ export class Parser { } else { throw new ParseException( 'Blank expressions are not allowed in interpolated strings', input, - `at column ${this._findInterpolationErrorColumn(parts, i)} in`, location); + `at column ${this._findInterpolationErrorColumn(parts, i, interpolationConfig)} in`, + location); } } return new SplitInterpolation(strings, expressions); @@ -146,19 +163,26 @@ export class Parser { return null; } - private _checkNoInterpolation(input: string, location: any): void { - var parts = StringWrapper.split(input, INTERPOLATION_REGEXP); + private _checkNoInterpolation( + input: string, location: any, interpolationConfig: InterpolationConfig): void { + var regexp = _createInterpolateRegExp(interpolationConfig); + var parts = StringWrapper.split(input, regexp); if (parts.length > 1) { throw new ParseException( - 'Got interpolation ({{}}) where expression was expected', input, - `at column ${this._findInterpolationErrorColumn(parts, 1)} in`, location); + `Got interpolation (${interpolationConfig.start}${interpolationConfig.end}) where expression was expected`, + input, + `at column ${this._findInterpolationErrorColumn(parts, 1, interpolationConfig)} in`, + location); } } - private _findInterpolationErrorColumn(parts: string[], partInErrIdx: number): number { + private _findInterpolationErrorColumn( + parts: string[], partInErrIdx: number, interpolationConfig: InterpolationConfig): number { var errLocation = ''; for (var j = 0; j < partInErrIdx; j++) { - errLocation += j % 2 === 0 ? parts[j] : `{{${parts[j]}}}`; + errLocation += j % 2 === 0 ? + parts[j] : + `${interpolationConfig.start}${parts[j]}${interpolationConfig.end}`; } return errLocation.length; diff --git a/modules/@angular/compiler/src/html_lexer.ts b/modules/@angular/compiler/src/html_lexer.ts index ecf20d8594..2211035b99 100644 --- a/modules/@angular/compiler/src/html_lexer.ts +++ b/modules/@angular/compiler/src/html_lexer.ts @@ -2,6 +2,7 @@ import * as chars from './chars'; import {ListWrapper} from './facade/collection'; import {NumberWrapper, StringWrapper, isBlank, isPresent} from './facade/lang'; import {HtmlTagContentType, NAMED_ENTITIES, getHtmlTagDefinition} from './html_tags'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from './interpolation_config'; import {ParseError, ParseLocation, ParseSourceFile, ParseSourceSpan} from './parse_util'; export enum HtmlTokenType { @@ -43,9 +44,11 @@ export class HtmlTokenizeResult { } export function tokenizeHtml( - sourceContent: string, sourceUrl: string, - tokenizeExpansionForms: boolean = false): HtmlTokenizeResult { - return new _HtmlTokenizer(new ParseSourceFile(sourceContent, sourceUrl), tokenizeExpansionForms) + sourceContent: string, sourceUrl: string, tokenizeExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): HtmlTokenizeResult { + return new _HtmlTokenizer( + new ParseSourceFile(sourceContent, sourceUrl), tokenizeExpansionForms, + interpolationConfig) .tokenize(); } @@ -81,7 +84,9 @@ class _HtmlTokenizer { tokens: HtmlToken[] = []; errors: HtmlTokenError[] = []; - constructor(private file: ParseSourceFile, private tokenizeExpansionForms: boolean) { + constructor( + private file: ParseSourceFile, private tokenizeExpansionForms: boolean, + private interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG) { this._input = file.content; this._length = file.content.length; this._advance(); @@ -114,7 +119,8 @@ class _HtmlTokenizer { this._consumeTagOpen(start); } } else if ( - isExpansionFormStart(this._peek, this._nextPeek) && this.tokenizeExpansionForms) { + isExpansionFormStart(this._input, this._index, this.interpolationConfig.start) && + this.tokenizeExpansionForms) { this._consumeExpansionFormStart(); } else if ( @@ -232,16 +238,12 @@ class _HtmlTokenizer { } private _attemptStr(chars: string): boolean { - var indexBeforeAttempt = this._index; - var columnBeforeAttempt = this._column; - var lineBeforeAttempt = this._line; + const initialPosition = this._savePosition(); for (var i = 0; i < chars.length; i++) { if (!this._attemptCharCode(StringWrapper.charCodeAt(chars, i))) { // If attempting to parse the string fails, we want to reset the parser // to where it was before the attempt - this._index = indexBeforeAttempt; - this._column = columnBeforeAttempt; - this._line = lineBeforeAttempt; + this._restorePosition(initialPosition); return false; } } @@ -558,35 +560,38 @@ class _HtmlTokenizer { var parts: string[] = []; let interpolation = false; - if (this._peek === chars.$LBRACE && this._nextPeek === chars.$LBRACE) { - parts.push(this._readChar(true)); - parts.push(this._readChar(true)); - interpolation = true; - } else { - parts.push(this._readChar(true)); - } - - while (!this._isTextEnd(interpolation)) { - if (this._peek === chars.$LBRACE && this._nextPeek === chars.$LBRACE) { - parts.push(this._readChar(true)); - parts.push(this._readChar(true)); + do { + const savedPos = this._savePosition(); + // _attemptStr advances the position when it is true. + // To push interpolation symbols, we have to reset it. + if (this._attemptStr(this.interpolationConfig.start)) { + this._restorePosition(savedPos); + for (let i = 0; i < this.interpolationConfig.start.length; i++) { + parts.push(this._readChar(true)); + } interpolation = true; - } else if ( - this._peek === chars.$RBRACE && this._nextPeek === chars.$RBRACE && interpolation) { - parts.push(this._readChar(true)); - parts.push(this._readChar(true)); + } else if (this._attemptStr(this.interpolationConfig.end) && interpolation) { + this._restorePosition(savedPos); + for (let i = 0; i < this.interpolationConfig.end.length; i++) { + parts.push(this._readChar(true)); + } interpolation = false; } else { + this._restorePosition(savedPos); parts.push(this._readChar(true)); } - } + } while (!this._isTextEnd(interpolation)); + this._endToken([this._processCarriageReturns(parts.join(''))]); } private _isTextEnd(interpolation: boolean): boolean { if (this._peek === chars.$LT || this._peek === chars.$EOF) return true; if (this.tokenizeExpansionForms) { - if (isExpansionFormStart(this._peek, this._nextPeek)) return true; + const savedPos = this._savePosition(); + if (isExpansionFormStart(this._input, this._index, this.interpolationConfig.start)) + return true; + this._restorePosition(savedPos); if (this._peek === chars.$RBRACE && !interpolation && (this._isInExpansionCase() || this._isInExpansionForm())) return true; @@ -655,8 +660,11 @@ function isNamedEntityEnd(code: number): boolean { return code == chars.$SEMICOLON || code == chars.$EOF || !isAsciiLetter(code); } -function isExpansionFormStart(peek: number, nextPeek: number): boolean { - return peek === chars.$LBRACE && nextPeek != chars.$LBRACE; +function isExpansionFormStart(input: string, offset: number, interpolationStart: string): boolean { + const substr = input.substring(offset); + return StringWrapper.charCodeAt(substr, 0) === chars.$LBRACE && + StringWrapper.charCodeAt(substr, 1) !== chars.$LBRACE && + !substr.startsWith(interpolationStart); } function isExpansionCaseStart(peek: number): boolean { diff --git a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts index 2ffb71cc1b..f0d5e8eb0c 100644 --- a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts @@ -4,6 +4,7 @@ import {BaseException} from '../facade/exceptions'; import {NumberWrapper, RegExpWrapper, isPresent} from '../facade/lang'; import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../html_ast'; import {HtmlParseTreeResult, HtmlParser} from '../html_parser'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../interpolation_config'; import {ParseError, ParseSourceSpan} from '../parse_util'; import {expandNodes} from './expander'; @@ -96,15 +97,19 @@ let _PLACEHOLDER_EXPANDED_REGEXP = /<\/ph>/gi; */ export class I18nHtmlParser implements HtmlParser { errors: ParseError[]; + private _interpolationConfig: InterpolationConfig; constructor( private _htmlParser: HtmlParser, private _parser: Parser, private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}, private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - parse(sourceContent: string, sourceUrl: string, parseExpansionForms: boolean = false): + parse( + sourceContent: string, sourceUrl: string, parseExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): HtmlParseTreeResult { this.errors = []; + this._interpolationConfig = interpolationConfig; let res = this._htmlParser.parse(sourceContent, sourceUrl, true); @@ -134,7 +139,7 @@ export class I18nHtmlParser implements HtmlParser { } private _mergeI18Part(part: Part): HtmlAst[] { - let message = part.createMessage(this._parser); + let message = part.createMessage(this._parser, this._interpolationConfig); let messageId = id(message); if (!StringMapWrapper.contains(this._messages, messageId)) { throw new I18nError( @@ -240,8 +245,8 @@ export class I18nHtmlParser implements HtmlParser { } private _mergeTextInterpolation(t: HtmlElementAst, originalNode: HtmlTextAst): HtmlTextAst { - let split = - this._parser.splitInterpolation(originalNode.value, originalNode.sourceSpan.toString()); + let split = this._parser.splitInterpolation( + originalNode.value, originalNode.sourceSpan.toString(), this._interpolationConfig); let exps = isPresent(split) ? split.expressions : []; let messageSubstring = @@ -277,9 +282,9 @@ export class I18nHtmlParser implements HtmlParser { res.push(attr); return; } - message = messageFromAttribute(this._parser, attr); + message = messageFromAttribute(this._parser, this._interpolationConfig, attr); } else { - message = messageFromI18nAttribute(this._parser, el, i18ns[0]); + message = messageFromI18nAttribute(this._parser, this._interpolationConfig, el, i18ns[0]); } let messageId = id(message); @@ -298,7 +303,8 @@ export class I18nHtmlParser implements HtmlParser { } private _replaceInterpolationInAttr(attr: HtmlAttrAst, msg: HtmlAst[]): string { - let split = this._parser.splitInterpolation(attr.value, attr.sourceSpan.toString()); + let split = this._parser.splitInterpolation( + attr.value, attr.sourceSpan.toString(), this._interpolationConfig); let exps = isPresent(split) ? split.expressions : []; let first = msg[0]; @@ -336,7 +342,7 @@ export class I18nHtmlParser implements HtmlParser { private _convertIntoExpression( name: string, expMap: Map, sourceSpan: ParseSourceSpan) { if (expMap.has(name)) { - return `{{${expMap.get(name)}}}`; + return `${this._interpolationConfig.start}${expMap.get(name)}${this._interpolationConfig.end}`; } else { throw new I18nError(sourceSpan, `Invalid interpolation name '${name}'`); } diff --git a/modules/@angular/compiler/src/i18n/message_extractor.ts b/modules/@angular/compiler/src/i18n/message_extractor.ts index 408e4fada1..0810c6b288 100644 --- a/modules/@angular/compiler/src/i18n/message_extractor.ts +++ b/modules/@angular/compiler/src/i18n/message_extractor.ts @@ -3,6 +3,7 @@ import {StringMapWrapper} from '../facade/collection'; import {isPresent} from '../facade/lang'; import {HtmlAst, HtmlElementAst} from '../html_ast'; import {HtmlParser} from '../html_parser'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../interpolation_config'; import {ParseError} from '../parse_util'; import {expandNodes} from './expander'; @@ -92,14 +93,18 @@ export function removeDuplicates(messages: Message[]): Message[] { export class MessageExtractor { private _messages: Message[]; private _errors: ParseError[]; + private _interpolationConfig: InterpolationConfig; constructor( private _htmlParser: HtmlParser, private _parser: Parser, private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - extract(template: string, sourceUrl: string): ExtractionResult { + extract( + template: string, sourceUrl: string, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ExtractionResult { this._messages = []; this._errors = []; + this._interpolationConfig = interpolationConfig; let res = this._htmlParser.parse(template, sourceUrl, true); if (res.errors.length > 0) { @@ -113,7 +118,7 @@ export class MessageExtractor { private _extractMessagesFromPart(part: Part): void { if (part.hasI18n) { - this._messages.push(part.createMessage(this._parser)); + this._messages.push(part.createMessage(this._parser, this._interpolationConfig)); this._recurseToExtractMessagesFromAttributes(part.children); } else { this._recurse(part.children); @@ -148,7 +153,8 @@ export class MessageExtractor { p.attrs.filter(attr => attr.name.startsWith(I18N_ATTR_PREFIX)).forEach(attr => { try { explicitAttrs.push(attr.name.substring(I18N_ATTR_PREFIX.length)); - this._messages.push(messageFromI18nAttribute(this._parser, p, attr)); + this._messages.push( + messageFromI18nAttribute(this._parser, this._interpolationConfig, p, attr)); } catch (e) { if (e instanceof I18nError) { this._errors.push(e); @@ -161,6 +167,8 @@ export class MessageExtractor { p.attrs.filter(attr => !attr.name.startsWith(I18N_ATTR_PREFIX)) .filter(attr => explicitAttrs.indexOf(attr.name) == -1) .filter(attr => transAttrs.indexOf(attr.name) > -1) - .forEach(attr => this._messages.push(messageFromAttribute(this._parser, attr))); + .forEach( + attr => this._messages.push( + messageFromAttribute(this._parser, this._interpolationConfig, attr))); } } diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts index 8e9d25abcd..dd85f8502b 100644 --- a/modules/@angular/compiler/src/i18n/shared.ts +++ b/modules/@angular/compiler/src/i18n/shared.ts @@ -1,6 +1,7 @@ import {Parser} from '../expression_parser/parser'; import {StringWrapper, isBlank, isPresent} from '../facade/lang'; import {HtmlAst, HtmlAstVisitor, HtmlAttrAst, HtmlCommentAst, HtmlElementAst, HtmlExpansionAst, HtmlExpansionCaseAst, HtmlTextAst, htmlVisitAll} from '../html_ast'; +import {InterpolationConfig} from '../interpolation_config'; import {ParseError, ParseSourceSpan} from '../parse_util'; import {Message} from './message'; @@ -61,9 +62,10 @@ export class Part { return this.children[0].sourceSpan; } - createMessage(parser: Parser): Message { + createMessage(parser: Parser, interpolationConfig: InterpolationConfig): Message { return new Message( - stringifyNodes(this.children, parser), meaning(this.i18n), description(this.i18n)); + stringifyNodes(this.children, parser, interpolationConfig), meaning(this.i18n), + description(this.i18n)); } } @@ -102,28 +104,31 @@ export function description(i18n: string): string { * @internal */ export function messageFromI18nAttribute( - parser: Parser, p: HtmlElementAst, i18nAttr: HtmlAttrAst): Message { + parser: Parser, interpolationConfig: InterpolationConfig, p: HtmlElementAst, + i18nAttr: HtmlAttrAst): Message { let expectedName = i18nAttr.name.substring(5); let attr = p.attrs.find(a => a.name == expectedName); if (attr) { - return messageFromAttribute(parser, attr, meaning(i18nAttr.value), description(i18nAttr.value)); + return messageFromAttribute( + parser, interpolationConfig, attr, meaning(i18nAttr.value), description(i18nAttr.value)); } throw new I18nError(p.sourceSpan, `Missing attribute '${expectedName}'.`); } export function messageFromAttribute( - parser: Parser, attr: HtmlAttrAst, meaning: string = null, - description: string = null): Message { - let value = removeInterpolation(attr.value, attr.sourceSpan, parser); + parser: Parser, interpolationConfig: InterpolationConfig, attr: HtmlAttrAst, + meaning: string = null, description: string = null): Message { + let value = removeInterpolation(attr.value, attr.sourceSpan, parser, interpolationConfig); return new Message(value, meaning, description); } export function removeInterpolation( - value: string, source: ParseSourceSpan, parser: Parser): string { + value: string, source: ParseSourceSpan, parser: Parser, + interpolationConfig: InterpolationConfig): string { try { - let parsed = parser.splitInterpolation(value, source.toString()); + let parsed = parser.splitInterpolation(value, source.toString(), interpolationConfig); let usedNames = new Map(); if (isPresent(parsed)) { let res = ''; @@ -160,14 +165,15 @@ export function dedupePhName(usedNames: Map, name: string): stri } } -export function stringifyNodes(nodes: HtmlAst[], parser: Parser): string { - let visitor = new _StringifyVisitor(parser); +export function stringifyNodes( + nodes: HtmlAst[], parser: Parser, interpolationConfig: InterpolationConfig): string { + let visitor = new _StringifyVisitor(parser, interpolationConfig); return htmlVisitAll(visitor, nodes).join(''); } class _StringifyVisitor implements HtmlAstVisitor { private _index: number = 0; - constructor(private _parser: Parser) {} + constructor(private _parser: Parser, private _interpolationConfig: InterpolationConfig) {} visitElement(ast: HtmlElementAst, context: any): any { let name = this._index++; @@ -179,7 +185,8 @@ class _StringifyVisitor implements HtmlAstVisitor { visitText(ast: HtmlTextAst, context: any): any { let index = this._index++; - let noInterpolation = removeInterpolation(ast.value, ast.sourceSpan, this._parser); + let noInterpolation = + removeInterpolation(ast.value, ast.sourceSpan, this._parser, this._interpolationConfig); if (noInterpolation != ast.value) { return `${noInterpolation}`; } diff --git a/modules/@angular/compiler/src/interpolation_config.ts b/modules/@angular/compiler/src/interpolation_config.ts new file mode 100644 index 0000000000..8d4d536062 --- /dev/null +++ b/modules/@angular/compiler/src/interpolation_config.ts @@ -0,0 +1,9 @@ +export interface InterpolationConfig { + start: string; + end: string; +} + +export const DEFAULT_INTERPOLATION_CONFIG: InterpolationConfig = { + start: '{{', + end: '}}' +}; diff --git a/modules/@angular/compiler/src/metadata_resolver.ts b/modules/@angular/compiler/src/metadata_resolver.ts index 90f6adacd7..aa86340a1d 100644 --- a/modules/@angular/compiler/src/metadata_resolver.ts +++ b/modules/@angular/compiler/src/metadata_resolver.ts @@ -5,7 +5,7 @@ import {StringMapWrapper} from '../src/facade/collection'; import {BaseException} from '../src/facade/exceptions'; import {Type, isArray, isBlank, isPresent, isString, isStringMap, stringify} from '../src/facade/lang'; -import {assertArrayOfStrings} from './assertions'; +import {assertArrayOfStrings, assertInterpolationSymbols} from './assertions'; import * as cpl from './compile_metadata'; import {CompilerConfig} from './config'; import {hasLifecycleHook} from './directive_lifecycle_reflector'; @@ -96,6 +96,7 @@ export class CompileMetadataResolver { var cmpMeta = dirMeta; var viewMeta = this._viewResolver.resolve(directiveType); assertArrayOfStrings('styles', viewMeta.styles); + assertInterpolationSymbols('interpolation', viewMeta.interpolation); var animations = isPresent(viewMeta.animations) ? viewMeta.animations.map(e => this.getAnimationEntryMetadata(e)) : null; @@ -106,7 +107,8 @@ export class CompileMetadataResolver { templateUrl: viewMeta.templateUrl, styles: viewMeta.styles, styleUrls: viewMeta.styleUrls, - animations: animations + animations: animations, + interpolation: viewMeta.interpolation }); changeDetectionStrategy = cmpMeta.changeDetection; if (isPresent(dirMeta.viewProviders)) { diff --git a/modules/@angular/compiler/src/template_parser.ts b/modules/@angular/compiler/src/template_parser.ts index 648af6cf72..33d42f3b3d 100644 --- a/modules/@angular/compiler/src/template_parser.ts +++ b/modules/@angular/compiler/src/template_parser.ts @@ -11,6 +11,7 @@ import {CompileDirectiveMetadata, CompilePipeMetadata, CompileMetadataWithType,} import {HtmlParser} from './html_parser'; import {splitNsName, mergeNsAndName} from './html_tags'; import {ParseSourceSpan, ParseError, ParseLocation, ParseErrorLevel} from './parse_util'; +import {InterpolationConfig} from './interpolation_config'; import {ElementAst, BoundElementPropertyAst, BoundEventAst, ReferenceAst, TemplateAst, TemplateAstVisitor, templateVisitAll, TextAst, BoundTextAst, EmbeddedTemplateAst, AttrAst, NgContentAst, PropertyBindingType, DirectiveAst, BoundDirectivePropertyAst, ProviderAst, ProviderAstType, VariableAst} from './template_ast'; import {CssSelector, SelectorMatcher} from './selector'; @@ -151,12 +152,20 @@ class TemplateParseVisitor implements HtmlAstVisitor { directivesIndex = new Map(); ngContentCount: number = 0; pipesByName: Map; + private _interpolationConfig: InterpolationConfig; constructor( public providerViewContext: ProviderViewContext, directives: CompileDirectiveMetadata[], pipes: CompilePipeMetadata[], private _exprParser: Parser, private _schemaRegistry: ElementSchemaRegistry) { this.selectorMatcher = new SelectorMatcher(); + const tempMeta = providerViewContext.component.template; + if (isPresent(tempMeta) && isPresent(tempMeta.interpolation)) { + this._interpolationConfig = { + start: tempMeta.interpolation[0], + end: tempMeta.interpolation[1] + }; + } ListWrapper.forEachWithIndex( directives, (directive: CompileDirectiveMetadata, index: number) => { var selector = CssSelector.parse(directive.selector); @@ -176,7 +185,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { private _parseInterpolation(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { var sourceInfo = sourceSpan.start.toString(); try { - var ast = this._exprParser.parseInterpolation(value, sourceInfo); + var ast = this._exprParser.parseInterpolation(value, sourceInfo, this._interpolationConfig); this._checkPipes(ast, sourceSpan); if (isPresent(ast) && (ast.ast).expressions.length > MAX_INTERPOLATION_VALUES) { @@ -193,7 +202,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { private _parseAction(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { var sourceInfo = sourceSpan.start.toString(); try { - var ast = this._exprParser.parseAction(value, sourceInfo); + var ast = this._exprParser.parseAction(value, sourceInfo, this._interpolationConfig); this._checkPipes(ast, sourceSpan); return ast; } catch (e) { @@ -205,7 +214,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { private _parseBinding(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { var sourceInfo = sourceSpan.start.toString(); try { - var ast = this._exprParser.parseBinding(value, sourceInfo); + var ast = this._exprParser.parseBinding(value, sourceInfo, this._interpolationConfig); this._checkPipes(ast, sourceSpan); return ast; } catch (e) { diff --git a/modules/@angular/compiler/src/view_resolver.ts b/modules/@angular/compiler/src/view_resolver.ts index 70bea86b8c..96990a13e0 100644 --- a/modules/@angular/compiler/src/view_resolver.ts +++ b/modules/@angular/compiler/src/view_resolver.ts @@ -51,7 +51,8 @@ export class ViewResolver { encapsulation: compMeta.encapsulation, styles: compMeta.styles, styleUrls: compMeta.styleUrls, - animations: compMeta.animations + animations: compMeta.animations, + interpolation: compMeta.interpolation }); } } else { diff --git a/modules/@angular/compiler/test/compile_metadata_spec.ts b/modules/@angular/compiler/test/compile_metadata_spec.ts index 1ac8be96c9..d239c96257 100644 --- a/modules/@angular/compiler/test/compile_metadata_spec.ts +++ b/modules/@angular/compiler/test/compile_metadata_spec.ts @@ -49,7 +49,8 @@ export function main() { new CompileAnimationAnimateMetadata( 1000, new CompileAnimationStyleMetadata(0, [{'opacity': 1}])) ]))])], - ngContentSelectors: ['*'] + ngContentSelectors: ['*'], + interpolation: ['{{', '}}'] }); fullDirectiveMeta = CompileDirectiveMetadata.create({ selector: 'someSelector', @@ -145,6 +146,11 @@ export function main() { var empty = new CompileTemplateMetadata(); expect(CompileTemplateMetadata.fromJson(empty.toJson())).toEqual(empty); }); + + it('should throw an error with invalid interpolation symbols', () => { + expect(() => new CompileTemplateMetadata({interpolation: ['{{']})) + .toThrowError(`'interpolation' should have a start and an end symbol.`); + }); }); describe('CompileAnimationStyleMetadata', () => { diff --git a/modules/@angular/compiler/test/expression_parser/parser_spec.ts b/modules/@angular/compiler/test/expression_parser/parser_spec.ts index 335257efd4..f978790e92 100644 --- a/modules/@angular/compiler/test/expression_parser/parser_spec.ts +++ b/modules/@angular/compiler/test/expression_parser/parser_spec.ts @@ -467,6 +467,14 @@ export function main() { checkInterpolation(`{{ 'foo' +\n 'bar' +\r 'baz' }}`, `{{ "foo" + "bar" + "baz" }}`); }); + it('should support custom interpolation', () => { + const parser = new Parser(new Lexer()); + const ast = parser.parseInterpolation('{% a %}', null, {start: '{%', end: '%}'}).ast as any; + expect(ast.strings).toEqual(['', '']); + expect(ast.expressions.length).toEqual(1); + expect(ast.expressions[0].name).toEqual('a'); + }); + describe('comments', () => { it('should ignore comments in interpolation expressions', () => { checkInterpolation('{{a //comment}}', '{{ a }}'); }); diff --git a/modules/@angular/compiler/test/expression_parser/unparser.ts b/modules/@angular/compiler/test/expression_parser/unparser.ts index c4f0f47c5d..96f972317a 100644 --- a/modules/@angular/compiler/test/expression_parser/unparser.ts +++ b/modules/@angular/compiler/test/expression_parser/unparser.ts @@ -1,12 +1,15 @@ import {AST, AstVisitor, Binary, BindingPipe, Chain, Conditional, EmptyExpr, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, PrefixNot, PropertyRead, PropertyWrite, Quote, SafeMethodCall, SafePropertyRead} from '../../src/expression_parser/ast'; import {StringWrapper, isPresent, isString} from '../../src/facade/lang'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../src/interpolation_config'; export class Unparser implements AstVisitor { private static _quoteRegExp = /"/g; private _expression: string; + private _interpolationConfig: InterpolationConfig; - unparse(ast: AST) { + unparse(ast: AST, interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG) { this._expression = ''; + this._interpolationConfig = interpolationConfig; this._visit(ast); return this._expression; } @@ -74,9 +77,9 @@ export class Unparser implements AstVisitor { for (let i = 0; i < ast.strings.length; i++) { this._expression += ast.strings[i]; if (i < ast.expressions.length) { - this._expression += '{{ '; + this._expression += `${this._interpolationConfig.start} `; this._visit(ast.expressions[i]); - this._expression += ' }}'; + this._expression += ` ${this._interpolationConfig.end}`; } } } diff --git a/modules/@angular/compiler/test/html_lexer_spec.ts b/modules/@angular/compiler/test/html_lexer_spec.ts index b8acecb93f..e23d85a638 100644 --- a/modules/@angular/compiler/test/html_lexer_spec.ts +++ b/modules/@angular/compiler/test/html_lexer_spec.ts @@ -1,4 +1,5 @@ import {HtmlToken, HtmlTokenError, HtmlTokenType, tokenizeHtml} from '@angular/compiler/src/html_lexer'; +import {InterpolationConfig} from '@angular/compiler/src/interpolation_config'; import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '@angular/compiler/src/parse_util'; import {afterEach, beforeEach, ddescribe, describe, expect, iit, it, xit} from '@angular/core/testing/testing_internal'; @@ -345,6 +346,16 @@ export function main() { ]); }); + it('should parse interpolation', () => { + expect(tokenizeAndHumanizeParts('{{ a }}')).toEqual([ + [HtmlTokenType.TEXT, '{{ a }}'], [HtmlTokenType.EOF] + ]); + + expect(tokenizeAndHumanizeParts('{% a %}', null, {start: '{%', end: '%}'})).toEqual([ + [HtmlTokenType.TEXT, '{% a %}'], [HtmlTokenType.EOF] + ]); + }); + it('should handle CR & LF', () => { expect(tokenizeAndHumanizeParts('t\ne\rs\r\nt')).toEqual([ [HtmlTokenType.TEXT, 't\ne\ns\nt'], [HtmlTokenType.EOF] @@ -577,8 +588,9 @@ export function main() { } function tokenizeWithoutErrors( - input: string, tokenizeExpansionForms: boolean = false): HtmlToken[] { - var tokenizeResult = tokenizeHtml(input, 'someUrl', tokenizeExpansionForms); + input: string, tokenizeExpansionForms: boolean = false, + interpolationConfig?: InterpolationConfig): HtmlToken[] { + var tokenizeResult = tokenizeHtml(input, 'someUrl', tokenizeExpansionForms, interpolationConfig); if (tokenizeResult.errors.length > 0) { var errorString = tokenizeResult.errors.join('\n'); throw new BaseException(`Unexpected parse errors:\n${errorString}`); @@ -586,8 +598,10 @@ function tokenizeWithoutErrors( return tokenizeResult.tokens; } -function tokenizeAndHumanizeParts(input: string, tokenizeExpansionForms: boolean = false): any[] { - return tokenizeWithoutErrors(input, tokenizeExpansionForms) +function tokenizeAndHumanizeParts( + input: string, tokenizeExpansionForms: boolean = false, + interpolationConfig?: InterpolationConfig): any[] { + return tokenizeWithoutErrors(input, tokenizeExpansionForms, interpolationConfig) .map(token => [token.type].concat(token.parts)); } diff --git a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts index 940a585161..92e278dbdb 100644 --- a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts @@ -5,6 +5,7 @@ import {HtmlParseTreeResult, HtmlParser} from '@angular/compiler/src/html_parser import {I18nHtmlParser} from '@angular/compiler/src/i18n/i18n_html_parser'; import {Message, id} from '@angular/compiler/src/i18n/message'; import {deserializeXmb} from '@angular/compiler/src/i18n/xmb_serializer'; +import {InterpolationConfig} from '@angular/compiler/src/interpolation_config'; import {ParseError} from '@angular/compiler/src/parse_util'; import {humanizeDom} from '@angular/compiler/test/html_ast_spec_utils'; import {ddescribe, describe, expect, iit, it} from '@angular/core/testing/testing_internal'; @@ -15,7 +16,8 @@ export function main() { describe('I18nHtmlParser', () => { function parse( template: string, messages: {[key: string]: string}, implicitTags: string[] = [], - implicitAttrs: {[k: string]: string[]} = {}): HtmlParseTreeResult { + implicitAttrs: {[k: string]: string[]} = {}, + interpolation?: InterpolationConfig): HtmlParseTreeResult { var parser = new Parser(new Lexer()); let htmlParser = new HtmlParser(); @@ -26,7 +28,7 @@ export function main() { return new I18nHtmlParser( htmlParser, parser, res.content, res.messages, implicitTags, implicitAttrs) - .parse(template, 'someurl', true); + .parse(template, 'someurl', true, interpolation); } it('should delegate to the provided parser when no i18n', () => { @@ -63,6 +65,17 @@ export function main() { .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]); }); + it('should handle interpolation with config', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message(' and ', null, null))] = + ' or '; + + expect(humanizeDom(parse( + '
', translations, [], {}, + {start: '{%', end: '%}'}))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{%b%} or {%a%}']]); + }); + it('should handle interpolation with custom placeholder names', () => { let translations: {[key: string]: string} = {}; translations[id(new Message(' and ', null, null))] = diff --git a/modules/@angular/compiler/test/metadata_resolver_spec.ts b/modules/@angular/compiler/test/metadata_resolver_spec.ts index 290a5e56ef..d7d6492295 100644 --- a/modules/@angular/compiler/test/metadata_resolver_spec.ts +++ b/modules/@angular/compiler/test/metadata_resolver_spec.ts @@ -35,6 +35,7 @@ export function main() { expect(meta.template.styleUrls).toEqual(['someStyleUrl']); expect(meta.template.template).toEqual('someTemplate'); expect(meta.template.templateUrl).toEqual('someTemplateUrl'); + expect(meta.template.interpolation).toEqual(['{{', '}}']); })); it('should use the moduleUrl from the reflector if none is given', @@ -61,6 +62,16 @@ export function main() { .toThrowError(`Can't resolve all parameters for MyBrokenComp1: (?).`); } })); + + it('should throw an error when the interpolation config has invalid symbols', + inject([CompileMetadataResolver], (resolver: CompileMetadataResolver) => { + expect(() => resolver.getDirectiveMetadata(ComponentWithInvalidInterpolation1)) + .toThrowError(`[' ', ' '] contains unusable interpolation symbol.`); + expect(() => resolver.getDirectiveMetadata(ComponentWithInvalidInterpolation2)) + .toThrowError(`['{', '}'] contains unusable interpolation symbol.`); + expect(() => resolver.getDirectiveMetadata(ComponentWithInvalidInterpolation3)) + .toThrowError(`['<%', '%>'] contains unusable interpolation symbol.`); + })); }); describe('getViewDirectivesMetadata', () => { @@ -120,7 +131,8 @@ class ComponentWithoutModuleId { encapsulation: ViewEncapsulation.Emulated, styles: ['someStyle'], styleUrls: ['someStyleUrl'], - directives: [SomeDirective] + directives: [SomeDirective], + interpolation: ['{{', '}}'] }) class ComponentWithEverything implements OnChanges, OnInit, DoCheck, OnDestroy, AfterContentInit, AfterContentChecked, AfterViewInit, @@ -139,3 +151,15 @@ class ComponentWithEverything implements OnChanges, class MyBrokenComp1 { constructor(public dependency: any) {} } + +@Component({selector: 'someSelector', template: '', interpolation: [' ', ' ']}) +class ComponentWithInvalidInterpolation1 { +} + +@Component({selector: 'someSelector', template: '', interpolation: ['{', '}']}) +class ComponentWithInvalidInterpolation2 { +} + +@Component({selector: 'someSelector', template: '', interpolation: ['<%', '%>']}) +class ComponentWithInvalidInterpolation3 { +} diff --git a/modules/@angular/compiler/test/template_parser_spec.ts b/modules/@angular/compiler/test/template_parser_spec.ts index 0ff93af825..0741f24e62 100644 --- a/modules/@angular/compiler/test/template_parser_spec.ts +++ b/modules/@angular/compiler/test/template_parser_spec.ts @@ -9,6 +9,7 @@ import {afterEach, beforeEach, beforeEachProviders, ddescribe, describe, expect, import {SecurityContext} from '../core_private'; import {Identifiers, identifierToken} from '../src/identifiers'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../src/interpolation_config'; import {Unparser} from './expression_parser/unparser'; import {TEST_PROVIDERS} from './test_bindings'; @@ -152,6 +153,20 @@ export function main() { expect(humanizeTplAst(parse('{{a}}', []))).toEqual([[BoundTextAst, '{{ a }}']]); }); + it('should parse with custom interpolation config', + inject([TemplateParser], (parser: TemplateParser) => { + const component = CompileDirectiveMetadata.create({ + selector: 'test', + type: new CompileTypeMetadata({moduleUrl: someModuleUrl, name: 'Test'}), + isComponent: true, + template: new CompileTemplateMetadata({interpolation: ['{%', '%}']}) + }); + expect(humanizeTplAst(parser.parse(component, '{%a%}', [], [], 'TestComp'), { + start: '{%', + end: '%}' + })).toEqual([[BoundTextAst, '{% a %}']]); + })); + describe('bound properties', () => { it('should parse mixed case bound properties', () => { @@ -1406,14 +1421,16 @@ The pipe 'test' could not be found ("[ERROR ->]{{a | test}}"): TestComp@0:0`); }); } -function humanizeTplAst(templateAsts: TemplateAst[]): any[] { - var humanizer = new TemplateHumanizer(false); +function humanizeTplAst( + templateAsts: TemplateAst[], interpolationConfig?: InterpolationConfig): any[] { + const humanizer = new TemplateHumanizer(false, interpolationConfig); templateVisitAll(humanizer, templateAsts); return humanizer.result; } -function humanizeTplAstSourceSpans(templateAsts: TemplateAst[]): any[] { - var humanizer = new TemplateHumanizer(true); +function humanizeTplAstSourceSpans( + templateAsts: TemplateAst[], interpolationConfig?: InterpolationConfig): any[] { + const humanizer = new TemplateHumanizer(true, interpolationConfig); templateVisitAll(humanizer, templateAsts); return humanizer.result; } @@ -1421,7 +1438,9 @@ function humanizeTplAstSourceSpans(templateAsts: TemplateAst[]): any[] { class TemplateHumanizer implements TemplateAstVisitor { result: any[] = []; - constructor(private includeSourceSpan: boolean){}; + constructor( + private includeSourceSpan: boolean, + private interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG){}; visitNgContent(ast: NgContentAst, context: any): any { var res = [NgContentAst]; @@ -1461,13 +1480,17 @@ class TemplateHumanizer implements TemplateAstVisitor { return null; } visitEvent(ast: BoundEventAst, context: any): any { - var res = [BoundEventAst, ast.name, ast.target, expressionUnparser.unparse(ast.handler)]; + var res = [ + BoundEventAst, ast.name, ast.target, + expressionUnparser.unparse(ast.handler, this.interpolationConfig) + ]; this.result.push(this._appendContext(ast, res)); return null; } visitElementProperty(ast: BoundElementPropertyAst, context: any): any { var res = [ - BoundElementPropertyAst, ast.type, ast.name, expressionUnparser.unparse(ast.value), ast.unit + BoundElementPropertyAst, ast.type, ast.name, + expressionUnparser.unparse(ast.value, this.interpolationConfig), ast.unit ]; this.result.push(this._appendContext(ast, res)); return null; @@ -1478,7 +1501,7 @@ class TemplateHumanizer implements TemplateAstVisitor { return null; } visitBoundText(ast: BoundTextAst, context: any): any { - var res = [BoundTextAst, expressionUnparser.unparse(ast.value)]; + var res = [BoundTextAst, expressionUnparser.unparse(ast.value, this.interpolationConfig)]; this.result.push(this._appendContext(ast, res)); return null; } @@ -1496,7 +1519,10 @@ class TemplateHumanizer implements TemplateAstVisitor { return null; } visitDirectiveProperty(ast: BoundDirectivePropertyAst, context: any): any { - var res = [BoundDirectivePropertyAst, ast.directiveName, expressionUnparser.unparse(ast.value)]; + var res = [ + BoundDirectivePropertyAst, ast.directiveName, + expressionUnparser.unparse(ast.value, this.interpolationConfig) + ]; this.result.push(this._appendContext(ast, res)); return null; } diff --git a/modules/@angular/compiler/testing/view_resolver_mock.ts b/modules/@angular/compiler/testing/view_resolver_mock.ts index 36ad6f8a01..bcea5651f1 100644 --- a/modules/@angular/compiler/testing/view_resolver_mock.ts +++ b/modules/@angular/compiler/testing/view_resolver_mock.ts @@ -111,7 +111,8 @@ export class MockViewResolver extends ViewResolver { styles: view.styles, styleUrls: view.styleUrls, pipes: view.pipes, - encapsulation: view.encapsulation + encapsulation: view.encapsulation, + interpolation: view.interpolation }); this._viewCache.set(component, view); diff --git a/modules/@angular/core/src/metadata.ts b/modules/@angular/core/src/metadata.ts index acf360979d..66af2feade 100644 --- a/modules/@angular/core/src/metadata.ts +++ b/modules/@angular/core/src/metadata.ts @@ -42,7 +42,8 @@ export interface ComponentDecorator extends TypeDecorator { renderer?: string, styles?: string[], styleUrls?: string[], - animations?: AnimationEntryMetadata[] + animations?: AnimationEntryMetadata[], + interpolation?: [string, string] }): ViewDecorator; } @@ -63,7 +64,8 @@ export interface ViewDecorator extends TypeDecorator { renderer?: string, styles?: string[], styleUrls?: string[], - animations?: AnimationEntryMetadata[] + animations?: AnimationEntryMetadata[], + interpolation?: [string, string] }): ViewDecorator; } @@ -175,7 +177,8 @@ export interface ComponentMetadataFactory { animations?: AnimationEntryMetadata[], directives?: Array, pipes?: Array, - encapsulation?: ViewEncapsulation + encapsulation?: ViewEncapsulation, + interpolation?: [string, string] }): ComponentDecorator; new (obj: { selector?: string, @@ -197,7 +200,8 @@ export interface ComponentMetadataFactory { animations?: AnimationEntryMetadata[], directives?: Array, pipes?: Array, - encapsulation?: ViewEncapsulation + encapsulation?: ViewEncapsulation, + interpolation?: [string, string] }): ComponentMetadata; } @@ -252,7 +256,8 @@ export interface ViewMetadataFactory { encapsulation?: ViewEncapsulation, styles?: string[], styleUrls?: string[], - animations?: AnimationEntryMetadata[] + animations?: AnimationEntryMetadata[], + interpolation?: [string, string] }): ViewDecorator; new (obj: { templateUrl?: string, @@ -262,7 +267,8 @@ export interface ViewMetadataFactory { encapsulation?: ViewEncapsulation, styles?: string[], styleUrls?: string[], - animations?: AnimationEntryMetadata[] + animations?: AnimationEntryMetadata[], + interpolation?: [string, string] }): ViewMetadata; } diff --git a/modules/@angular/core/src/metadata/directives.ts b/modules/@angular/core/src/metadata/directives.ts index 0762948a25..e283af7bc3 100644 --- a/modules/@angular/core/src/metadata/directives.ts +++ b/modules/@angular/core/src/metadata/directives.ts @@ -954,6 +954,8 @@ export class ComponentMetadata extends DirectiveMetadata { encapsulation: ViewEncapsulation; + interpolation: [string, string]; + constructor({selector, inputs, outputs, @@ -973,7 +975,8 @@ export class ComponentMetadata extends DirectiveMetadata { animations, directives, pipes, - encapsulation}: { + encapsulation, + interpolation}: { selector?: string, inputs?: string[], outputs?: string[], @@ -993,7 +996,8 @@ export class ComponentMetadata extends DirectiveMetadata { animations?: AnimationEntryMetadata[], directives?: Array, pipes?: Array, - encapsulation?: ViewEncapsulation + encapsulation?: ViewEncapsulation, + interpolation?: [string, string] } = {}) { super({ selector: selector, @@ -1018,6 +1022,7 @@ export class ComponentMetadata extends DirectiveMetadata { this.encapsulation = encapsulation; this.moduleId = moduleId; this.animations = animations; + this.interpolation = interpolation; } } diff --git a/modules/@angular/core/src/metadata/view.ts b/modules/@angular/core/src/metadata/view.ts index b6d3ad6230..0b9189085a 100644 --- a/modules/@angular/core/src/metadata/view.ts +++ b/modules/@angular/core/src/metadata/view.ts @@ -128,8 +128,11 @@ export class ViewMetadata { animations: AnimationEntryMetadata[]; + interpolation: [string, string]; + constructor( - {templateUrl, template, directives, pipes, encapsulation, styles, styleUrls, animations}: { + {templateUrl, template, directives, pipes, encapsulation, styles, styleUrls, animations, + interpolation}: { templateUrl?: string, template?: string, directives?: Array, @@ -137,7 +140,8 @@ export class ViewMetadata { encapsulation?: ViewEncapsulation, styles?: string[], styleUrls?: string[], - animations?: AnimationEntryMetadata[] + animations?: AnimationEntryMetadata[], + interpolation?: [string, string] } = {}) { this.templateUrl = templateUrl; this.template = template; @@ -147,5 +151,6 @@ export class ViewMetadata { this.pipes = pipes; this.encapsulation = encapsulation; this.animations = animations; + this.interpolation = interpolation; } } diff --git a/modules/@angular/core/test/linker/integration_spec.ts b/modules/@angular/core/test/linker/integration_spec.ts index cd105b3488..15ba1ad857 100644 --- a/modules/@angular/core/test/linker/integration_spec.ts +++ b/modules/@angular/core/test/linker/integration_spec.ts @@ -1300,6 +1300,31 @@ function declareTests({useJit}: {useJit: boolean}) { async.done(); }); })); + + it('should support custom interpolation', + inject( + [TestComponentBuilder, AsyncTestCompleter], + (tcb: TestComponentBuilder, async: AsyncTestCompleter) => { + tcb.overrideView( + MyComp, new ViewMetadata({ + template: `
{{ctxProp}}
+ +`, + directives: [ + ComponentWithCustomInterpolationA, ComponentWithCustomInterpolationB + ] + })) + .createAsync(MyComp) + .then((fixture) => { + fixture.debugElement.componentInstance.ctxProp = 'Default Interpolation'; + + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement) + .toHaveText( + 'Default Interpolation\nCustom Interpolation A\nCustom Interpolation B (Default Interpolation)'); + async.done(); + }); + })); }); describe('dependency injection', () => { @@ -2023,6 +2048,32 @@ function declareTests({useJit}: {useJit: boolean}) { }); } + +@Component({selector: 'cmp-with-default-interpolation', template: `{{text}}`}) +class ComponentWithDefaultInterpolation { + text = 'Default Interpolation'; +} + +@Component({ + selector: 'cmp-with-custom-interpolation-a', + template: `
{%text%}
`, + interpolation: ['{%', '%}'] +}) +class ComponentWithCustomInterpolationA { + text = 'Custom Interpolation A'; +} + +@Component({ + selector: 'cmp-with-custom-interpolation-b', + template: + `
{**text%}
()`, + interpolation: ['{**', '%}'], + directives: [ComponentWithDefaultInterpolation] +}) +class ComponentWithCustomInterpolationB { + text = 'Custom Interpolation B'; +} + @Injectable() class MyService { greeting: string; diff --git a/modules/@angular/facade/src/lang.dart b/modules/@angular/facade/src/lang.dart index 57e3bd3c00..1a53c9fcef 100644 --- a/modules/@angular/facade/src/lang.dart +++ b/modules/@angular/facade/src/lang.dart @@ -367,3 +367,7 @@ bool hasConstructor(Object value, Type type) { String escape(String s) { return Uri.encodeComponent(s); } + +String escapeRegExp(String s) { + return s.replaceAllMapped(new RegExp(r'([.*+?^=!:${}()|[\]\/\\])'), (Match m) => '\\${m[1]}'); +} diff --git a/modules/@angular/facade/src/lang.ts b/modules/@angular/facade/src/lang.ts index 5a581abb9a..5cb7ae6b46 100644 --- a/modules/@angular/facade/src/lang.ts +++ b/modules/@angular/facade/src/lang.ts @@ -466,3 +466,7 @@ export function hasConstructor(value: Object, type: Type): boolean { export function escape(s: string): string { return _global.encodeURI(s); } + +export function escapeRegExp(s: string): string { + return s.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); +} diff --git a/tools/public_api_guard/public_api_spec.ts b/tools/public_api_guard/public_api_spec.ts index a0adf792be..329d20d5f5 100644 --- a/tools/public_api_guard/public_api_spec.ts +++ b/tools/public_api_guard/public_api_spec.ts @@ -131,7 +131,7 @@ const CORE = [ 'CollectionChangeRecord.toString():string', 'CollectionChangeRecord.trackById:any', 'ComponentDecorator', - 'ComponentDecorator.View(obj:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, renderer?:string, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[]}):ViewDecorator', + 'ComponentDecorator.View(obj:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, renderer?:string, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[], interpolation?:[string, string]}):ViewDecorator', 'ComponentFactory.componentType:Type', 'ComponentFactory.constructor(selector:string, _viewFactory:Function, _componentType:Type)', 'ComponentFactory.create(injector:Injector, projectableNodes:any[][]=null, rootSelectorOrNode:string|any=null):ComponentRef', @@ -140,9 +140,10 @@ const CORE = [ 'ComponentMetadata', 'ComponentMetadata.animations:AnimationEntryMetadata[]', 'ComponentMetadata.changeDetection:ChangeDetectionStrategy', - 'ComponentMetadata.constructor({selector,inputs,outputs,properties,events,host,exportAs,moduleId,providers,viewProviders,changeDetection=ChangeDetectionStrategy.Default,queries,templateUrl,template,styleUrls,styles,animations,directives,pipes,encapsulation}:{selector?:string, inputs?:string[], outputs?:string[], properties?:string[], events?:string[], host?:{[key:string]:string}, providers?:any[], exportAs?:string, moduleId?:string, viewProviders?:any[], queries?:{[key:string]:any}, changeDetection?:ChangeDetectionStrategy, templateUrl?:string, template?:string, styleUrls?:string[], styles?:string[], animations?:AnimationEntryMetadata[], directives?:Array, pipes?:Array, encapsulation?:ViewEncapsulation}={})', + 'ComponentMetadata.constructor({selector,inputs,outputs,properties,events,host,exportAs,moduleId,providers,viewProviders,changeDetection=ChangeDetectionStrategy.Default,queries,templateUrl,template,styleUrls,styles,animations,directives,pipes,encapsulation,interpolation}:{selector?:string, inputs?:string[], outputs?:string[], properties?:string[], events?:string[], host?:{[key:string]:string}, providers?:any[], exportAs?:string, moduleId?:string, viewProviders?:any[], queries?:{[key:string]:any}, changeDetection?:ChangeDetectionStrategy, templateUrl?:string, template?:string, styleUrls?:string[], styles?:string[], animations?:AnimationEntryMetadata[], directives?:Array, pipes?:Array, encapsulation?:ViewEncapsulation, interpolation?:[string, string]}={})', 'ComponentMetadata.directives:Array', 'ComponentMetadata.encapsulation:ViewEncapsulation', + 'ComponentMetadata.interpolation:[string, string]', 'ComponentMetadata.moduleId:string', 'ComponentMetadata.pipes:Array', 'ComponentMetadata.styles:string[]', @@ -592,16 +593,17 @@ const CORE = [ 'ViewContainerRef.parentInjector:Injector', 'ViewContainerRef.remove(index?:number):void', 'ViewDecorator', - 'ViewDecorator.View(obj:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, renderer?:string, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[]}):ViewDecorator', + 'ViewDecorator.View(obj:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, renderer?:string, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[], interpolation?:[string, string]}):ViewDecorator', 'ViewEncapsulation', 'ViewEncapsulation.Emulated', 'ViewEncapsulation.Native', 'ViewEncapsulation.None', 'ViewMetadata', 'ViewMetadata.animations:AnimationEntryMetadata[]', - 'ViewMetadata.constructor({templateUrl,template,directives,pipes,encapsulation,styles,styleUrls,animations}:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, encapsulation?:ViewEncapsulation, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[]}={})', + 'ViewMetadata.constructor({templateUrl,template,directives,pipes,encapsulation,styles,styleUrls,animations,interpolation}:{templateUrl?:string, template?:string, directives?:Array, pipes?:Array, encapsulation?:ViewEncapsulation, styles?:string[], styleUrls?:string[], animations?:AnimationEntryMetadata[], interpolation?:[string, string]}={})', 'ViewMetadata.directives:Array', 'ViewMetadata.encapsulation:ViewEncapsulation', + 'ViewMetadata.interpolation:[string, string]', 'ViewMetadata.pipes:Array', 'ViewMetadata.styles:string[]', 'ViewMetadata.styleUrls:string[]', @@ -1229,9 +1231,10 @@ const COMPILER = [ 'CompilerConfig.useJit:boolean', 'CompileTemplateMetadata', 'CompileTemplateMetadata.animations:CompileAnimationEntryMetadata[]', - 'CompileTemplateMetadata.constructor({encapsulation,template,templateUrl,styles,styleUrls,animations,ngContentSelectors}:{encapsulation?:ViewEncapsulation, template?:string, templateUrl?:string, styles?:string[], styleUrls?:string[], ngContentSelectors?:string[], animations?:CompileAnimationEntryMetadata[]}={})', + 'CompileTemplateMetadata.constructor({encapsulation,template,templateUrl,styles,styleUrls,animations,ngContentSelectors,interpolation}:{encapsulation?:ViewEncapsulation, template?:string, templateUrl?:string, styles?:string[], styleUrls?:string[], ngContentSelectors?:string[], animations?:CompileAnimationEntryMetadata[], interpolation?:[string, string]}={})', 'CompileTemplateMetadata.encapsulation:ViewEncapsulation', 'CompileTemplateMetadata.fromJson(data:{[key:string]:any}):CompileTemplateMetadata', + 'CompileTemplateMetadata.interpolation:[string, string]', 'CompileTemplateMetadata.ngContentSelectors:string[]', 'CompileTemplateMetadata.styles:string[]', 'CompileTemplateMetadata.styleUrls:string[]',