diff --git a/modules/angular2/src/compiler/html_lexer.ts b/modules/angular2/src/compiler/html_lexer.ts index 5b56a9b94d..9c604d9acc 100644 --- a/modules/angular2/src/compiler/html_lexer.ts +++ b/modules/angular2/src/compiler/html_lexer.ts @@ -6,7 +6,6 @@ import { CONST_EXPR, serializeEnum } from 'angular2/src/facade/lang'; -import {BaseException} from 'angular2/src/facade/exceptions'; import {ParseLocation, ParseError, ParseSourceFile, ParseSourceSpan} from './parse_util'; import {getHtmlTagDefinition, HtmlTagContentType, NAMED_ENTITIES} from './html_tags'; @@ -50,12 +49,14 @@ export function tokenizeHtml(sourceContent: string, sourceUrl: string): HtmlToke const $EOF = 0; const $TAB = 9; const $LF = 10; +const $FF = 12; const $CR = 13; const $SPACE = 32; const $BANG = 33; const $DQ = 34; +const $HASH = 35; const $$ = 36; const $AMPERSAND = 38; const $SQ = 39; @@ -76,7 +77,9 @@ const $Z = 90; const $LBRACKET = 91; const $RBRACKET = 93; const $a = 97; +const $f = 102; const $z = 122; +const $x = 120; const $NBSP = 160; @@ -86,7 +89,7 @@ function unexpectedCharacterErrorMsg(charCode: number): string { } function unknownEntityErrorMsg(entitySrc: string): string { - return `Unknown entity "${entitySrc}"`; + return `Unknown entity "${entitySrc}" - use the "&#;" or "&#x;" syntax`; } class ControlFlowError { @@ -249,16 +252,7 @@ class _HtmlTokenizer { private _readChar(decodeEntities: boolean): string { if (decodeEntities && this.peek === $AMPERSAND) { - var start = this._getLocation(); - this._attemptUntilChar($SEMICOLON); - this._advance(); - var entitySrc = this.input.substring(start.offset + 1, this.index - 1); - var decodedEntity = decodeEntity(entitySrc); - if (isPresent(decodedEntity)) { - return decodedEntity; - } else { - throw this._createError(unknownEntityErrorMsg(entitySrc), start); - } + return this._decodeEntity(); } else { var index = this.index; this._advance(); @@ -266,6 +260,42 @@ class _HtmlTokenizer { } } + private _decodeEntity(): string { + var start = this._getLocation(); + this._advance(); + if (this._attemptChar($HASH)) { + let isHex = this._attemptChar($x); + let numberStart = this._getLocation().offset; + this._attemptUntilFn(isDigitEntityEnd); + if (this.peek != $SEMICOLON) { + throw this._createError(unexpectedCharacterErrorMsg(this.peek), this._getLocation()); + } + this._advance(); + let strNum = this.input.substring(numberStart, this.index - 1); + try { + let charCode = NumberWrapper.parseInt(strNum, isHex ? 16 : 10); + return StringWrapper.fromCharCode(charCode); + } catch (e) { + let entity = this.input.substring(start.offset + 1, this.index - 1); + throw this._createError(unknownEntityErrorMsg(entity), start); + } + } else { + let startPosition = this._savePosition(); + this._attemptUntilFn(isNamedEntityEnd); + if (this.peek != $SEMICOLON) { + this._restorePosition(startPosition); + return '&'; + } + this._advance(); + let name = this.input.substring(start.offset + 1, this.index - 1); + let char = NAMED_ENTITIES[name]; + if (isBlank(char)) { + throw this._createError(unknownEntityErrorMsg(name), start); + } + return char; + } + } + private _consumeRawText(decodeEntities: boolean, firstCharOfEnd: number, attemptEndRest: Function): HtmlToken { var tagCloseStart; @@ -428,6 +458,15 @@ class _HtmlTokenizer { } this._endToken([parts.join('')]); } + + private _savePosition(): number[] { return [this.peek, this.index, this.column, this.line]; } + + private _restorePosition(position: number[]): void { + this.peek = position[0]; + this.index = position[1]; + this.column = position[2]; + this.line = position[3]; + } } function isNotWhitespace(code: number): boolean { @@ -440,39 +479,29 @@ function isWhitespace(code: number): boolean { function isNameEnd(code: number): boolean { return isWhitespace(code) || code === $GT || code === $SLASH || code === $SQ || code === $DQ || - code === $EQ + code === $EQ; } function isPrefixEnd(code: number): boolean { return (code < $a || $z < code) && (code < $A || $Z < code) && (code < $0 || code > $9); } +function isDigitEntityEnd(code: number): boolean { + return code == $SEMICOLON || code == $EOF || !isAsciiHexDigit(code); +} + +function isNamedEntityEnd(code: number): boolean { + return code == $SEMICOLON || code == $EOF || !isAsciiLetter(code); +} + function isTextEnd(code: number): boolean { return code === $LT || code === $EOF; } -function decodeEntity(entity: string): string { - var i = 0; - var isNumber = entity.length > i && entity[i] == '#'; - if (isNumber) i++; - var isHex = entity.length > i && entity[i] == 'x'; - if (isHex) i++; - var value = entity.substring(i); - var result = null; - if (isNumber) { - var charCode; - try { - charCode = NumberWrapper.parseInt(value, isHex ? 16 : 10); - } catch (e) { - return null; - } - result = StringWrapper.fromCharCode(charCode); - } else { - result = NAMED_ENTITIES[value]; - } - if (isPresent(result)) { - return result; - } else { - return null; - } +function isAsciiLetter(code: number): boolean { + return code >= $a && code <= $z; +} + +function isAsciiHexDigit(code: number): boolean { + return code >= $a && code <= $f || code >= $0 && code <= $9; } diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index ea303e71da..05d9fefe53 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -9,34 +9,22 @@ import { serializeEnum, CONST_EXPR } from 'angular2/src/facade/lang'; -import {DOM} from 'angular2/src/core/dom/dom_adapter'; + import {ListWrapper} from 'angular2/src/facade/collection'; import {HtmlAst, HtmlAttrAst, HtmlTextAst, HtmlElementAst} from './html_ast'; -import {escapeDoubleQuoteString} from './util'; import {Injectable} from 'angular2/src/core/di'; import {HtmlToken, HtmlTokenType, tokenizeHtml} from './html_lexer'; import {ParseError, ParseLocation, ParseSourceSpan} from './parse_util'; import {HtmlTagDefinition, getHtmlTagDefinition} from './html_tags'; -// TODO: remove this, just provide a plain error message! -export enum HtmlTreeErrorType { - UnexpectedClosingTag -} - -const HTML_ERROR_TYPE_MSGS = CONST_EXPR(['Unexpected closing tag']); - - export class HtmlTreeError extends ParseError { - static create(type: HtmlTreeErrorType, elementName: string, - location: ParseLocation): HtmlTreeError { - return new HtmlTreeError(type, HTML_ERROR_TYPE_MSGS[serializeEnum(type)], elementName, - location); + static create(elementName: string, location: ParseLocation, msg: string): HtmlTreeError { + return new HtmlTreeError(elementName, location, msg); } - constructor(public type: HtmlTreeErrorType, msg: string, public elementName: string, - location: ParseLocation) { + constructor(public elementName: string, location: ParseLocation, msg: string) { super(location, msg); } } @@ -55,11 +43,8 @@ export class HtmlParser { } } -var NS_PREFIX_RE = /^@[^:]+/g; - class TreeBuilder { private index: number = -1; - private length: number; private peek: HtmlToken; private rootNodes: HtmlAst[] = []; @@ -129,7 +114,7 @@ class TreeBuilder { while (this.peek.type === HtmlTokenType.ATTR_NAME) { attrs.push(this._consumeAttr(this._advance())); } - var fullName = elementName(prefix, name, this._getParentElement()); + var fullName = getElementFullName(prefix, name, this._getParentElement()); var voidElement = false; // Note: There could have been a tokenizer error // so that we don't get a token for the end tag... @@ -150,15 +135,12 @@ class TreeBuilder { } private _pushElement(el: HtmlElementAst) { - var stackIndex = this.elementStack.length - 1; - while (stackIndex >= 0) { - var parentEl = this.elementStack[stackIndex]; - if (!getHtmlTagDefinition(parentEl.name).isClosedByChild(el.name)) { - break; + if (this.elementStack.length > 0) { + var parentEl = ListWrapper.last(this.elementStack); + if (getHtmlTagDefinition(parentEl.name).isClosedByChild(el.name)) { + this.elementStack.pop(); } - stackIndex--; } - this.elementStack.splice(stackIndex, this.elementStack.length - 1 - stackIndex); var tagDef = getHtmlTagDefinition(el.name); var parentEl = this._getParentElement(); @@ -175,35 +157,29 @@ class TreeBuilder { private _consumeEndTag(endTagToken: HtmlToken) { var fullName = - elementName(endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); + getElementFullName(endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); if (!this._popElement(fullName)) { - this.errors.push(HtmlTreeError.create(HtmlTreeErrorType.UnexpectedClosingTag, fullName, - endTagToken.sourceSpan.start)); + this.errors.push(HtmlTreeError.create(fullName, endTagToken.sourceSpan.start, + `Unexpected closing tag "${endTagToken.parts[1]}"`)); } } private _popElement(fullName: string): boolean { - var stackIndex = this.elementStack.length - 1; - var hasError = false; - while (stackIndex >= 0) { + for (let stackIndex = this.elementStack.length - 1; stackIndex >= 0; stackIndex--) { var el = this.elementStack[stackIndex]; - if (el.name == fullName) { - break; + if (el.name.toLowerCase() == fullName.toLowerCase()) { + ListWrapper.splice(this.elementStack, stackIndex, this.elementStack.length - stackIndex); + return true; } if (!getHtmlTagDefinition(el.name).closedByParent) { - hasError = true; - break; + return false; } - stackIndex--; } - if (!hasError) { - this.elementStack.splice(stackIndex, this.elementStack.length - stackIndex); - } - return !hasError; + return false; } private _consumeAttr(attrName: HtmlToken): HtmlAttrAst { - var fullName = elementName(attrName.parts[0], attrName.parts[1], null); + var fullName = mergeNsAndName(attrName.parts[0], attrName.parts[1]); var end = attrName.sourceSpan.end; var value = ''; if (this.peek.type === HtmlTokenType.ATTR_VALUE) { @@ -228,20 +204,24 @@ class TreeBuilder { } } -function elementName(prefix: string, localName: string, parentElement: HtmlElementAst) { +function mergeNsAndName(prefix: string, localName: string): string { + return isPresent(prefix) ? `@${prefix}:${localName}` : localName; +} + +function getElementFullName(prefix: string, localName: string, + parentElement: HtmlElementAst): string { if (isBlank(prefix)) { prefix = getHtmlTagDefinition(localName).implicitNamespacePrefix; + if (isBlank(prefix) && isPresent(parentElement)) { + prefix = namespacePrefix(parentElement.name); + } } - if (isBlank(prefix) && isPresent(parentElement)) { - prefix = namespacePrefix(parentElement.name); - } - if (isPresent(prefix)) { - return `@${prefix}:${localName}`; - } else { - return localName; - } + + return mergeNsAndName(prefix, localName); } +var NS_PREFIX_RE = /^@([^:]+)/g; + function namespacePrefix(elementName: string): string { var match = RegExpWrapper.firstMatch(NS_PREFIX_RE, elementName); return isBlank(match) ? null : match[1]; diff --git a/modules/angular2/src/compiler/html_tags.ts b/modules/angular2/src/compiler/html_tags.ts index a88bc363e4..07fc901a4d 100644 --- a/modules/angular2/src/compiler/html_tags.ts +++ b/modules/angular2/src/compiler/html_tags.ts @@ -1,7 +1,61 @@ import {isPresent, isBlank, normalizeBool, CONST_EXPR} from 'angular2/src/facade/lang'; -// TODO: fill this! -export const NAMED_ENTITIES: {[key: string]: string} = CONST_EXPR({'amp': '&'}); +// see http://www.w3.org/TR/html51/syntax.html#named-character-references +// see https://html.spec.whatwg.org/multipage/entities.json +// This list is not exhaustive to keep the compiler footprint low. +// The `{` / `ƫ` syntax should be used when the named character reference does not exist. +export const NAMED_ENTITIES = CONST_EXPR({ + 'lt': '<', + 'gt': '>', + 'nbsp': '\u00A0', + 'amp': '&', + 'Aacute': '\u00C1', + 'Acirc': '\u00C2', + 'Agrave': '\u00C0', + 'Atilde': '\u00C3', + 'Auml': '\u00C4', + 'Ccedil': '\u00C7', + 'Eacute': '\u00C9', + 'Ecirc': '\u00CA', + 'Egrave': '\u00C8', + 'Euml': '\u00CB', + 'Iacute': '\u00CD', + 'Icirc': '\u00CE', + 'Igrave': '\u00CC', + 'Iuml': '\u00CF', + 'Oacute': '\u00D3', + 'Ocirc': '\u00D4', + 'Ograve': '\u00D2', + 'Otilde': '\u00D5', + 'Ouml': '\u00D6', + 'Uacute': '\u00DA', + 'Ucirc': '\u00DB', + 'Ugrave': '\u00D9', + 'Uuml': '\u00DC', + 'aacute': '\u00E1', + 'acirc': '\u00E2', + 'agrave': '\u00E0', + 'atilde': '\u00E3', + 'auml': '\u00E4', + 'ccedil': '\u00E7', + 'eacute': '\u00E9', + 'ecirc': '\u00EA', + 'egrave': '\u00E8', + 'euml': '\u00EB', + 'iacute': '\u00ED', + 'icirc': '\u00EE', + 'igrave': '\u00EC', + 'iuml': '\u00EF', + 'oacute': '\u00F3', + 'ocirc': '\u00F4', + 'ograve': '\u00F2', + 'otilde': '\u00F5', + 'ouml': '\u00F6', + 'uacute': '\u00FA', + 'ucirc': '\u00FB', + 'ugrave': '\u00F9', + 'uuml': '\u00FC', +}); export enum HtmlTagContentType { RAW_TEXT, @@ -11,54 +65,77 @@ export enum HtmlTagContentType { export class HtmlTagDefinition { private closedByChildren: {[key: string]: boolean} = {}; - public closedByParent: boolean; + public closedByParent: boolean = false; public requiredParent: string; public implicitNamespacePrefix: string; public contentType: HtmlTagContentType; - constructor({closedByChildren, requiredParent, implicitNamespacePrefix, contentType}: { - closedByChildren?: string[], + constructor({closedByChildren, requiredParent, implicitNamespacePrefix, contentType, + closedByParent}: { + closedByChildren?: string, + closedByParent?: boolean, requiredParent?: string, implicitNamespacePrefix?: string, contentType?: HtmlTagContentType } = {}) { - if (isPresent(closedByChildren)) { - closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true); + if (isPresent(closedByChildren) && closedByChildren.length > 0) { + closedByChildren.split(',').forEach(tagName => this.closedByChildren[tagName.trim()] = true); } - this.closedByParent = isPresent(closedByChildren) && closedByChildren.length > 0; + this.closedByParent = normalizeBool(closedByParent); this.requiredParent = requiredParent; this.implicitNamespacePrefix = implicitNamespacePrefix; this.contentType = isPresent(contentType) ? contentType : HtmlTagContentType.PARSABLE_DATA; } - requireExtraParent(currentParent: string) { + requireExtraParent(currentParent: string): boolean { return isPresent(this.requiredParent) && - (isBlank(currentParent) || this.requiredParent != currentParent.toLocaleLowerCase()); + (isBlank(currentParent) || this.requiredParent != currentParent.toLowerCase()); } - isClosedByChild(name: string) { + isClosedByChild(name: string): boolean { return normalizeBool(this.closedByChildren['*']) || normalizeBool(this.closedByChildren[name.toLowerCase()]); } } -// TODO: Fill this table using -// https://github.com/greim/html-tokenizer/blob/master/parser.js -// and http://www.w3.org/TR/html51/syntax.html#optional-tags +// see http://www.w3.org/TR/html51/syntax.html#optional-tags +// This implementation does not fully conform to the HTML5 spec. var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { - 'link': new HtmlTagDefinition({closedByChildren: ['*']}), - 'ng-content': new HtmlTagDefinition({closedByChildren: ['*']}), - 'img': new HtmlTagDefinition({closedByChildren: ['*']}), - 'input': new HtmlTagDefinition({closedByChildren: ['*']}), - 'p': new HtmlTagDefinition({closedByChildren: ['p']}), - 'tr': new HtmlTagDefinition({closedByChildren: ['tr'], requiredParent: 'tbody'}), - 'col': new HtmlTagDefinition({closedByChildren: ['col'], requiredParent: 'colgroup'}), + 'link': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'ng-content': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'img': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'input': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'hr': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'br': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'wbr': new HtmlTagDefinition({closedByChildren: '*', closedByParent: true}), + 'p': new HtmlTagDefinition({ + closedByChildren: + 'address,article,aside,blockquote,div,dl,fieldset,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,main,nav,ol,p,pre,section,table,ul', + closedByParent: true + }), + 'thead': new HtmlTagDefinition({closedByChildren: 'tbody,tfoot'}), + 'tbody': new HtmlTagDefinition({closedByChildren: 'tbody,tfoot', closedByParent: true}), + 'tfoot': new HtmlTagDefinition({closedByChildren: 'tbody', closedByParent: true}), + 'tr': new HtmlTagDefinition( + {closedByChildren: 'tr', requiredParent: 'tbody', closedByParent: true}), + 'td': new HtmlTagDefinition({closedByChildren: 'td,th', closedByParent: true}), + 'th': new HtmlTagDefinition({closedByChildren: 'td,th', closedByParent: true}), + 'col': new HtmlTagDefinition({closedByChildren: 'col', requiredParent: 'colgroup'}), 'svg': new HtmlTagDefinition({implicitNamespacePrefix: 'svg'}), 'math': new HtmlTagDefinition({implicitNamespacePrefix: 'math'}), + 'li': new HtmlTagDefinition({closedByChildren: 'li', closedByParent: true}), + 'dt': new HtmlTagDefinition({closedByChildren: 'dt,dd'}), + 'dd': new HtmlTagDefinition({closedByChildren: 'dt,dd', closedByParent: true}), + 'rb': new HtmlTagDefinition({closedByChildren: 'rb,rt,rtc,rp', closedByParent: true}), + 'rt': new HtmlTagDefinition({closedByChildren: 'rb,rt,rtc,rp', closedByParent: true}), + 'rtc': new HtmlTagDefinition({closedByChildren: 'rb,rtc,rp', closedByParent: true}), + 'rp': new HtmlTagDefinition({closedByChildren: 'rb,rt,rtc,rp', closedByParent: true}), + 'optgroup': new HtmlTagDefinition({closedByChildren: 'optgroup', closedByParent: true}), + 'option': new HtmlTagDefinition({closedByChildren: 'option,optgroup', closedByParent: true}), 'style': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), 'script': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), 'title': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}), - 'textarea': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}) + 'textarea': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}), }; var DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); diff --git a/modules/angular2/src/compiler/parse_util.ts b/modules/angular2/src/compiler/parse_util.ts index 4c9413cce8..6fc087ebc8 100644 --- a/modules/angular2/src/compiler/parse_util.ts +++ b/modules/angular2/src/compiler/parse_util.ts @@ -1,10 +1,8 @@ -import {Math} from 'angular2/src/facade/math'; - export class ParseLocation { constructor(public file: ParseSourceFile, public offset: number, public line: number, public col: number) {} - toString() { return `${this.file.url}@${this.line}:${this.col}`; } + toString(): string { return `${this.file.url}@${this.line}:${this.col}`; } } export class ParseSourceFile { @@ -16,9 +14,40 @@ export abstract class ParseError { toString(): string { var source = this.location.file.content; - var ctxStart = Math.max(this.location.offset - 10, 0); - var ctxEnd = Math.min(this.location.offset + 10, source.length); - return `${this.msg} (${source.substring(ctxStart, ctxEnd)}): ${this.location}`; + var ctxStart = this.location.offset; + if (ctxStart > source.length - 1) { + ctxStart = source.length - 1; + } + var ctxEnd = ctxStart; + var ctxLen = 0; + var ctxLines = 0; + + while (ctxLen < 100 && ctxStart > 0) { + ctxStart--; + ctxLen++; + if (source[ctxStart] == "\n") { + if (++ctxLines == 3) { + break; + } + } + } + + ctxLen = 0; + ctxLines = 0; + while (ctxLen < 100 && ctxEnd < source.length - 1) { + ctxEnd++; + ctxLen++; + if (source[ctxEnd] == "\n") { + if (++ctxLines == 3) { + break; + } + } + } + + let context = source.substring(ctxStart, this.location.offset) + '[ERROR ->]' + + source.substring(this.location.offset, ctxEnd + 1); + + return `${this.msg} ("${context}"): ${this.location}`; } } diff --git a/modules/angular2/src/compiler/template_parser.ts b/modules/angular2/src/compiler/template_parser.ts index 40d279b4b9..173e356dac 100644 --- a/modules/angular2/src/compiler/template_parser.ts +++ b/modules/angular2/src/compiler/template_parser.ts @@ -64,7 +64,7 @@ import {dashCaseToCamelCase, camelCaseToDashCase, splitAtColon} from './util'; // Group 7 = idenitifer inside [] // Group 8 = identifier inside () var BIND_NAME_REGEXP = - /^(?:(?:(?:(bind-)|(var-|#)|(on-)|(bindon-))(.+))|\[\(([^\)]+)\)\]|\[([^\]]+)\]|\(([^\)]+)\))$/g; + /^(?:(?:(?:(bind-)|(var-|#)|(on-)|(bindon-))(.+))|\[\(([^\)]+)\)\]|\[([^\]]+)\]|\(([^\)]+)\))$/ig; const TEMPLATE_ELEMENT = 'template'; const TEMPLATE_ATTR = 'template'; @@ -218,7 +218,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { } }); - var isTemplateElement = nodeName == TEMPLATE_ELEMENT; + var isTemplateElement = nodeName.toLowerCase() == TEMPLATE_ELEMENT; var elementCssSelector = createElementCssSelector(nodeName, matchableAttrs); var directives = this._createDirectiveAsts( element.name, this._parseDirectives(this.selectorMatcher, elementCssSelector), @@ -266,7 +266,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { targetProps: BoundElementOrDirectiveProperty[], targetVars: VariableAst[]): boolean { var templateBindingsSource = null; - if (attr.name == TEMPLATE_ATTR) { + if (attr.name.toLowerCase() == TEMPLATE_ATTR) { templateBindingsSource = attr.value; } else if (attr.name.startsWith(TEMPLATE_ATTR_PREFIX)) { var key = attr.name.substring(TEMPLATE_ATTR_PREFIX.length); // remove the star @@ -347,7 +347,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { } private _normalizeAttributeName(attrName: string): string { - return attrName.startsWith('data-') ? attrName.substring(5) : attrName; + return attrName.toLowerCase().startsWith('data-') ? attrName.substring(5) : attrName; } private _parseVariable(identifier: string, value: string, sourceSpan: ParseSourceSpan, @@ -542,21 +542,25 @@ class TemplateParseVisitor implements HtmlAstVisitor { `Can't bind to '${boundPropertyName}' since it isn't a known native property`, sourceSpan); } - } else if (parts[0] == ATTRIBUTE_PREFIX) { - boundPropertyName = dashCaseToCamelCase(parts[1]); - bindingType = PropertyBindingType.Attribute; - } else if (parts[0] == CLASS_PREFIX) { - // keep original case! - boundPropertyName = parts[1]; - bindingType = PropertyBindingType.Class; - } else if (parts[0] == STYLE_PREFIX) { - unit = parts.length > 2 ? parts[2] : null; - boundPropertyName = dashCaseToCamelCase(parts[1]); - bindingType = PropertyBindingType.Style; } else { - this._reportError(`Invalid property name ${name}`, sourceSpan); - bindingType = null; + let lcPrefix = parts[0].toLowerCase(); + if (lcPrefix == ATTRIBUTE_PREFIX) { + boundPropertyName = dashCaseToCamelCase(parts[1]); + bindingType = PropertyBindingType.Attribute; + } else if (lcPrefix == CLASS_PREFIX) { + // keep original case! + boundPropertyName = parts[1]; + bindingType = PropertyBindingType.Class; + } else if (lcPrefix == STYLE_PREFIX) { + unit = parts.length > 2 ? parts[2] : null; + boundPropertyName = dashCaseToCamelCase(parts[1]); + bindingType = PropertyBindingType.Style; + } else { + this._reportError(`Invalid property name ${name}`, sourceSpan); + bindingType = null; + } } + return new BoundElementPropertyAst(boundPropertyName, bindingType, ast, unit, sourceSpan); } diff --git a/modules/angular2/src/compiler/template_preparser.ts b/modules/angular2/src/compiler/template_preparser.ts index 28640b9fe0..25a8eb287c 100644 --- a/modules/angular2/src/compiler/template_preparser.ts +++ b/modules/angular2/src/compiler/template_preparser.ts @@ -17,18 +17,19 @@ export function preparseElement(ast: HtmlElementAst): PreparsedElement { var relAttr = null; var nonBindable = false; ast.attrs.forEach(attr => { - if (attr.name == NG_CONTENT_SELECT_ATTR) { + let attrName = attr.name.toLowerCase(); + if (attrName == NG_CONTENT_SELECT_ATTR) { selectAttr = attr.value; - } else if (attr.name == LINK_STYLE_HREF_ATTR) { + } else if (attrName == LINK_STYLE_HREF_ATTR) { hrefAttr = attr.value; - } else if (attr.name == LINK_STYLE_REL_ATTR) { + } else if (attrName == LINK_STYLE_REL_ATTR) { relAttr = attr.value; - } else if (attr.name == NG_NON_BINDABLE_ATTR) { + } else if (attrName == NG_NON_BINDABLE_ATTR) { nonBindable = true; } }); selectAttr = normalizeNgContentSelect(selectAttr); - var nodeName = ast.name; + var nodeName = ast.name.toLowerCase(); var type = PreparsedElementType.OTHER; if (nodeName == NG_CONTENT_ELEMENT) { type = PreparsedElementType.NG_CONTENT; diff --git a/modules/angular2/src/platform/dom/dom_renderer.ts b/modules/angular2/src/platform/dom/dom_renderer.ts index 8fc869f63e..ffa57f486b 100644 --- a/modules/angular2/src/platform/dom/dom_renderer.ts +++ b/modules/angular2/src/platform/dom/dom_renderer.ts @@ -41,98 +41,11 @@ import { import {camelCaseToDashCase} from './util'; import {ViewEncapsulation} from 'angular2/src/core/metadata'; - // TODO move it once DomAdapter is moved import {DOM} from 'angular2/src/core/dom/dom_adapter'; -// TODO(tbosch): solve SVG properly once https://github.com/angular/angular/issues/4417 is done -const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink'; -const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; -const SVG_ELEMENT_NAMES = CONST_EXPR({ - 'altGlyph': true, - 'altGlyphDef': true, - 'altGlyphItem': true, - 'animate': true, - 'animateColor': true, - 'animateMotion': true, - 'animateTransform': true, - 'circle': true, - 'clipPath': true, - 'color-profile': true, - 'cursor': true, - 'defs': true, - 'desc': true, - 'ellipse': true, - 'feBlend': true, - 'feColorMatrix': true, - 'feComponentTransfer': true, - 'feComposite': true, - 'feConvolveMatrix': true, - 'feDiffuseLighting': true, - 'feDisplacementMap': true, - 'feDistantLight': true, - 'feFlood': true, - 'feFuncA': true, - 'feFuncB': true, - 'feFuncG': true, - 'feFuncR': true, - 'feGaussianBlur': true, - 'feImage': true, - 'feMerge': true, - 'feMergeNode': true, - 'feMorphology': true, - 'feOffset': true, - 'fePointLight': true, - 'feSpecularLighting': true, - 'feSpotLight': true, - 'feTile': true, - 'feTurbulence': true, - 'filter': true, - 'font': true, - 'font-face': true, - 'font-face-format': true, - 'font-face-name': true, - 'font-face-src': true, - 'font-face-uri': true, - 'foreignObject': true, - 'g': true, - // TODO(tbosch): this needs to be disabled - // because of an internal project. - // We will fix SVG soon, so this will go away... - // 'glyph': true, - 'glyphRef': true, - 'hkern': true, - 'image': true, - 'line': true, - 'linearGradient': true, - 'marker': true, - 'mask': true, - 'metadata': true, - 'missing-glyph': true, - 'mpath': true, - 'path': true, - 'pattern': true, - 'polygon': true, - 'polyline': true, - 'radialGradient': true, - 'rect': true, - 'set': true, - 'stop': true, - 'style': true, - 'svg': true, - 'switch': true, - 'symbol': true, - 'text': true, - 'textPath': true, - 'title': true, - 'tref': true, - 'tspan': true, - 'use': true, - 'view': true, - 'vkern': true -}); - -const SVG_ATTR_NAMESPACES = CONST_EXPR({'href': XLINK_NAMESPACE, 'xlink:href': XLINK_NAMESPACE}); +const NAMESPACE_URIS = + CONST_EXPR({'xlink': 'http://www.w3.org/1999/xlink', 'svg': 'http://www.w3.org/2000/svg'}); export abstract class DomRenderer extends Renderer implements NodeFactory { abstract registerComponentTemplate(template: RenderComponentTemplate); @@ -374,24 +287,31 @@ export class DomRenderer_ extends DomRenderer { wtfLeave(s); } createElement(name: string, attrNameAndValues: string[]): Node { - var isSvg = SVG_ELEMENT_NAMES[name] == true; - var el = isSvg ? DOM.createElementNS(SVG_NAMESPACE, name) : DOM.createElement(name); - this._setAttributes(el, attrNameAndValues, isSvg); + var nsAndName = splitNamespace(name); + var el = isPresent(nsAndName[0]) ? + DOM.createElementNS(NAMESPACE_URIS[nsAndName[0]], nsAndName[1]) : + DOM.createElement(nsAndName[1]); + this._setAttributes(el, attrNameAndValues); return el; } mergeElement(existing: Node, attrNameAndValues: string[]) { DOM.clearNodes(existing); - this._setAttributes(existing, attrNameAndValues, false); + this._setAttributes(existing, attrNameAndValues); } - private _setAttributes(node: Node, attrNameAndValues: string[], isSvg: boolean) { + private _setAttributes(node: Node, attrNameAndValues: string[]) { for (var attrIdx = 0; attrIdx < attrNameAndValues.length; attrIdx += 2) { + var attrNs; var attrName = attrNameAndValues[attrIdx]; + var nsAndName = splitNamespace(attrName); + if (isPresent(nsAndName[0])) { + attrName = nsAndName[0] + ':' + nsAndName[1]; + attrNs = NAMESPACE_URIS[nsAndName[0]]; + } var attrValue = attrNameAndValues[attrIdx + 1]; - var attrNs = isSvg ? SVG_ATTR_NAMESPACES[attrName] : null; if (isPresent(attrNs)) { - DOM.setAttributeNS(node, XLINK_NAMESPACE, attrName, attrValue); + DOM.setAttributeNS(node, attrNs, attrName, attrValue); } else { - DOM.setAttribute(node, attrName, attrValue); + DOM.setAttribute(node, nsAndName[1], attrValue); } } } @@ -442,3 +362,13 @@ function decoratePreventDefault(eventHandler: Function): Function { } }; } + +var NS_PREFIX_RE = /^@([^:]+):(.+)/g; + +function splitNamespace(name: string): string[] { + if (name[0] != '@') { + return [null, name]; + } + let match = RegExpWrapper.firstMatch(NS_PREFIX_RE, name); + return [match[1], match[2]]; +} diff --git a/modules/angular2/test/compiler/html_lexer_spec.ts b/modules/angular2/test/compiler/html_lexer_spec.ts index 8c7d74d9d2..2caba1af75 100644 --- a/modules/angular2/test/compiler/html_lexer_spec.ts +++ b/modules/angular2/test/compiler/html_lexer_spec.ts @@ -1,8 +1,22 @@ -import {ddescribe, describe, it, iit, xit, expect, beforeEach, afterEach} from '../../test_lib'; -import {BaseException} from '../../src/facade/exceptions'; +import { + ddescribe, + describe, + it, + iit, + xit, + expect, + beforeEach, + afterEach +} from 'angular2/testing_internal'; +import {BaseException} from 'angular2/src/facade/exceptions'; -import {tokenizeHtml, HtmlToken, HtmlTokenType} from '../../src/compiler/html_lexer'; -import {ParseSourceSpan, ParseLocation} from '../../src/compiler/parse_util'; +import { + tokenizeHtml, + HtmlToken, + HtmlTokenType, + HtmlTokenError +} from 'angular2/src/compiler/html_lexer'; +import {ParseSourceSpan, ParseLocation, ParseSourceFile} from 'angular2/src/compiler/parse_util'; export function main() { describe('HtmlLexer', () => { @@ -253,11 +267,35 @@ export function main() { }); it('should parse attributes with entities in values', () => { - expect(tokenizeAndHumanizeParts('')) + expect(tokenizeAndHumanizeParts('')) .toEqual([ [HtmlTokenType.TAG_OPEN_START, null, 't'], [HtmlTokenType.ATTR_NAME, null, 'a'], - [HtmlTokenType.ATTR_VALUE, 'A'], + [HtmlTokenType.ATTR_VALUE, 'AA'], + [HtmlTokenType.TAG_OPEN_END], + [HtmlTokenType.EOF] + ]); + }); + + it('should not decode entities without trailing ";"', () => { + expect(tokenizeAndHumanizeParts('')) + .toEqual([ + [HtmlTokenType.TAG_OPEN_START, null, 't'], + [HtmlTokenType.ATTR_NAME, null, 'a'], + [HtmlTokenType.ATTR_VALUE, '&'], + [HtmlTokenType.ATTR_NAME, null, 'b'], + [HtmlTokenType.ATTR_VALUE, 'c&&d'], + [HtmlTokenType.TAG_OPEN_END], + [HtmlTokenType.EOF] + ]); + }); + + it('should parse attributes with "&" in values', () => { + expect(tokenizeAndHumanizeParts('')) + .toEqual([ + [HtmlTokenType.TAG_OPEN_START, null, 't'], + [HtmlTokenType.ATTR_NAME, null, 'a'], + [HtmlTokenType.ATTR_VALUE, 'b && c &'], [HtmlTokenType.TAG_OPEN_END], [HtmlTokenType.EOF] ]); @@ -343,13 +381,22 @@ export function main() { .toEqual([[HtmlTokenType.TEXT, 'a&b'], [HtmlTokenType.EOF, '']]); }); - it('should report unknown named entities >', () => { + it('should report malformed/unknown entities', () => { expect(tokenizeAndHumanizeErrors('&tbo;')) - .toEqual([[HtmlTokenType.TEXT, 'Unknown entity "tbo"', '0:0']]); + .toEqual([ + [ + HtmlTokenType.TEXT, + 'Unknown entity "tbo" - use the "&#;" or "&#x;" syntax', + '0:0' + ] + ]); expect(tokenizeAndHumanizeErrors('&#asdf;')) - .toEqual([[HtmlTokenType.TEXT, 'Unknown entity "#asdf"', '0:0']]); + .toEqual([[HtmlTokenType.TEXT, 'Unexpected character "s"', '0:3']]); expect(tokenizeAndHumanizeErrors(' sdf;')) - .toEqual([[HtmlTokenType.TEXT, 'Unknown entity "#xasdf"', '0:0']]); + .toEqual([[HtmlTokenType.TEXT, 'Unexpected character "s"', '0:4']]); + + expect(tokenizeAndHumanizeErrors('઼')) + .toEqual([[HtmlTokenType.TEXT, 'Unexpected character "EOF"', '0:6']]); }); }); @@ -364,6 +411,11 @@ export function main() { .toEqual([[HtmlTokenType.TEXT, 'a&b'], [HtmlTokenType.EOF]]); }); + it('should parse text starting with "&"', () => { + expect(tokenizeAndHumanizeParts('a && b &')) + .toEqual([[HtmlTokenType.TEXT, 'a && b &'], [HtmlTokenType.EOF]]); + }); + it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('a')) .toEqual([[HtmlTokenType.TEXT, 'a'], [HtmlTokenType.EOF, '']]); @@ -486,6 +538,17 @@ export function main() { }); + describe('errors', () => { + it('should include 2 lines of context in message', () => { + let src = "111\n222\n333\nE\n444\n555\n666\n"; + let file = new ParseSourceFile(src, 'file://'); + let location = new ParseLocation(file, 12, 123, 456); + let error = new HtmlTokenError('**ERROR**', null, location); + expect(error.toString()) + .toEqual(`**ERROR** ("\n222\n333\n[ERROR ->]E\n444\n555\n"): file://@123:456`); + }); + }); + }); } diff --git a/modules/angular2/test/compiler/html_parser_spec.ts b/modules/angular2/test/compiler/html_parser_spec.ts index 21618378b2..9120a05ecf 100644 --- a/modules/angular2/test/compiler/html_parser_spec.ts +++ b/modules/angular2/test/compiler/html_parser_spec.ts @@ -9,8 +9,8 @@ import { afterEach } from 'angular2/testing_internal'; - -import {HtmlParser, HtmlParseTreeResult} from 'angular2/src/compiler/html_parser'; +import {HtmlTokenType} from 'angular2/src/compiler/html_lexer'; +import {HtmlParser, HtmlParseTreeResult, HtmlTreeError} from 'angular2/src/compiler/html_parser'; import { HtmlAst, HtmlAstVisitor, @@ -19,17 +19,15 @@ import { HtmlTextAst, htmlVisitAll } from 'angular2/src/compiler/html_ast'; +import {ParseError, ParseLocation, ParseSourceSpan} from 'angular2/src/compiler/parse_util'; + +import {BaseException} from 'angular2/src/facade/exceptions'; export function main() { describe('HtmlParser', () => { var parser: HtmlParser; beforeEach(() => { parser = new HtmlParser(); }); - // TODO: add more test cases - // TODO: separate tests for source spans from tests for tree parsing - // TODO: find a better way to assert the tree structure! - // -> maybe with arrays and object hashes!! - describe('parse', () => { describe('text nodes', () => { it('should parse root level text nodes', () => { @@ -38,37 +36,101 @@ export function main() { it('should parse text nodes inside regular elements', () => { expect(humanizeDom(parser.parse('
a
', 'TestComp'))) - .toEqual([[HtmlElementAst, 'div'], [HtmlTextAst, 'a']]); + .toEqual([[HtmlElementAst, 'div', 0], [HtmlTextAst, 'a']]); }); it('should parse text nodes inside template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) - .toEqual([[HtmlElementAst, 'template'], [HtmlTextAst, 'a']]); + .toEqual([[HtmlElementAst, 'template', 0], [HtmlTextAst, 'a']]); + }); + + it('should parse CDATA', () => { + expect(humanizeDom(parser.parse('', 'TestComp'))) + .toEqual([[HtmlTextAst, 'text']]); }); }); describe('elements', () => { it('should parse root level elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) - .toEqual([[HtmlElementAst, 'div']]); + .toEqual([[HtmlElementAst, 'div', 0]]); }); it('should parse elements inside of regular elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) - .toEqual([[HtmlElementAst, 'div'], [HtmlElementAst, 'span']]); + .toEqual([[HtmlElementAst, 'div', 0], [HtmlElementAst, 'span', 1]]); }); it('should parse elements inside of template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) - .toEqual([[HtmlElementAst, 'template'], [HtmlElementAst, 'span']]); + .toEqual([[HtmlElementAst, 'template', 0], [HtmlElementAst, 'span', 1]]); + }); + + it('should support void elements', () => { + expect(humanizeDom(parser.parse('', 'TestComp'))) + .toEqual([ + [HtmlElementAst, 'link', 0], + [HtmlAttrAst, 'rel', 'author license'], + [HtmlAttrAst, 'href', '/about'], + ]); + }); + + it('should support optional end tags', () => { + expect(humanizeDom(parser.parse('

1

2

', 'TestComp'))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'p', 1], + [HtmlTextAst, '1'], + [HtmlElementAst, 'p', 1], + [HtmlTextAst, '2'], + ]); + }); + + it('should support nested elements', () => { + expect(humanizeDom(parser.parse('
', 'TestComp'))) + .toEqual([ + [HtmlElementAst, 'ul', 0], + [HtmlElementAst, 'li', 1], + [HtmlElementAst, 'ul', 2], + [HtmlElementAst, 'li', 3], + ]); + }); + + it('should add the requiredParent', () => { + expect(humanizeDom(parser.parse('
', 'TestComp'))) + .toEqual([ + [HtmlElementAst, 'table', 0], + [HtmlElementAst, 'tbody', 1], + [HtmlElementAst, 'tr', 2], + ]); + }); + + it('should support explicit mamespace', () => { + expect(humanizeDom(parser.parse('', 'TestComp'))) + .toEqual([[HtmlElementAst, '@myns:div', 0]]); + }); + + it('should support implicit mamespace', () => { + expect(humanizeDom(parser.parse('', 'TestComp'))) + .toEqual([[HtmlElementAst, '@svg:svg', 0]]); + }); + + it('should propagate the namespace', () => { + expect(humanizeDom(parser.parse('

', 'TestComp'))) + .toEqual([[HtmlElementAst, '@myns:div', 0], [HtmlElementAst, '@myns:p', 1]]); + }); + + it('should match closing tags case insensitive', () => { + expect(humanizeDom(parser.parse('

', 'TestComp'))) + .toEqual([[HtmlElementAst, 'DiV', 0], [HtmlElementAst, 'P', 1]]); }); }); describe('attributes', () => { - it('should parse attributes on regular elements', () => { + it('should parse attributes on regular elements case sensitive', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) .toEqual([ - [HtmlElementAst, 'div'], + [HtmlElementAst, 'div', 0], [HtmlAttrAst, 'kEy', 'v'], [HtmlAttrAst, 'key2', 'v2'], ]); @@ -76,51 +138,135 @@ export function main() { it('should parse attributes without values', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) - .toEqual([[HtmlElementAst, 'div'], [HtmlAttrAst, 'k', '']]); + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'k', '']]); }); it('should parse attributes on svg elements case sensitive', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) - .toEqual([[HtmlElementAst, '@svg:svg'], [HtmlAttrAst, 'viewBox', '0']]); + .toEqual([[HtmlElementAst, '@svg:svg', 0], [HtmlAttrAst, 'viewBox', '0']]); }); it('should parse attributes on template elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) - .toEqual([[HtmlElementAst, 'template'], [HtmlAttrAst, 'k', 'v']]); + .toEqual([[HtmlElementAst, 'template', 0], [HtmlAttrAst, 'k', 'v']]); }); + it('should support mamespace', () => { + expect(humanizeDom(parser.parse('', 'TestComp'))) + .toEqual([[HtmlElementAst, 'use', 0], [HtmlAttrAst, '@xlink:href', 'Port']]); + }); + }); + + describe('comments', () => { + it('should ignore comments', () => { + expect(humanizeDom(parser.parse('
', 'TestComp'))) + .toEqual([[HtmlElementAst, 'div', 0]]); + }); + }); + + describe('source spans', () => { + it('should store the location', () => { + expect(humanizeDomSourceSpans(parser.parse( + '
\na\n
', 'TestComp'))) + .toEqual([ + [HtmlElementAst, 'div', 0, '
'], + [HtmlAttrAst, '[prop]', 'v1', '[prop]="v1"'], + [HtmlAttrAst, '(e)', 'do()', '(e)="do()"'], + [HtmlAttrAst, 'attr', 'v2', 'attr="v2"'], + [HtmlAttrAst, 'noValue', '', 'noValue'], + [HtmlTextAst, '\na\n', '\na\n'], + ]); + }); + }); + + describe('errors', () => { + it('should report unexpected closing tags', () => { + let errors = parser.parse('

', 'TestComp').errors; + expect(errors.length).toEqual(1); + expect(humanizeErrors(errors)).toEqual([['p', 'Unexpected closing tag "p"', '0:5']]); + }); + + it('should also report lexer errors', () => { + let errors = parser.parse('

', 'TestComp').errors; + expect(errors.length).toEqual(2); + expect(humanizeErrors(errors)) + .toEqual([ + [HtmlTokenType.COMMENT_START, 'Unexpected character "e"', '0:3'], + ['p', 'Unexpected closing tag "p"', '0:14'] + ]); + }); }); }); }); } function humanizeDom(parseResult: HtmlParseTreeResult): any[] { - // TODO: humanize errors as well! if (parseResult.errors.length > 0) { - throw parseResult.errors; + var errorString = parseResult.errors.join('\n'); + throw new BaseException(`Unexpected parse errors:\n${errorString}`); } - var humanizer = new Humanizer(); + + var humanizer = new Humanizer(false); htmlVisitAll(humanizer, parseResult.rootNodes); return humanizer.result; } +function humanizeDomSourceSpans(parseResult: HtmlParseTreeResult): any[] { + if (parseResult.errors.length > 0) { + var errorString = parseResult.errors.join('\n'); + throw new BaseException(`Unexpected parse errors:\n${errorString}`); + } + + var humanizer = new Humanizer(true); + htmlVisitAll(humanizer, parseResult.rootNodes); + return humanizer.result; +} + +function humanizeLineColumn(location: ParseLocation): string { + return `${location.line}:${location.col}`; +} + +function humanizeErrors(errors: ParseError[]): any[] { + return errors.map(error => { + if (error instanceof HtmlTreeError) { + // Parser errors + return [error.elementName, error.msg, humanizeLineColumn(error.location)]; + } + // Tokenizer errors + return [(error).tokenType, error.msg, humanizeLineColumn(error.location)]; + }); +} + class Humanizer implements HtmlAstVisitor { result: any[] = []; + elDepth: number = 0; + + constructor(private includeSourceSpan: boolean){}; visitElement(ast: HtmlElementAst, context: any): any { - this.result.push([HtmlElementAst, ast.name]); + var res = this._appendContext(ast, [HtmlElementAst, ast.name, this.elDepth++]); + this.result.push(res); htmlVisitAll(this, ast.attrs); htmlVisitAll(this, ast.children); + this.elDepth--; return null; } visitAttr(ast: HtmlAttrAst, context: any): any { - this.result.push([HtmlAttrAst, ast.name, ast.value]); + var res = this._appendContext(ast, [HtmlAttrAst, ast.name, ast.value]); + this.result.push(res); return null; } visitText(ast: HtmlTextAst, context: any): any { - this.result.push([HtmlTextAst, ast.value]); + var res = this._appendContext(ast, [HtmlTextAst, ast.value]); + this.result.push(res); return null; } + + private _appendContext(ast: HtmlAst, input: any[]): any[] { + if (!this.includeSourceSpan) return input; + input.push(ast.sourceSpan.toString()); + return input; + } } diff --git a/modules/angular2/test/compiler/template_parser_spec.ts b/modules/angular2/test/compiler/template_parser_spec.ts index 9e288b65f2..830cb09f8d 100644 --- a/modules/angular2/test/compiler/template_parser_spec.ts +++ b/modules/angular2/test/compiler/template_parser_spec.ts @@ -45,9 +45,6 @@ import {Unparser} from '../core/change_detection/parser/unparser'; var expressionUnparser = new Unparser(); -// TODO(tbosch): add tests for checking that we -// keep the correct sourceSpans! - export function main() { describe('TemplateParser', () => { beforeEachProviders(() => [ @@ -76,27 +73,35 @@ export function main() { describe('nodes without bindings', () => { it('should parse text nodes', - () => { expect(humanizeTemplateAsts(parse('a', []))).toEqual([[TextAst, 'a']]); }); + () => { expect(humanizeTplAst(parse('a', []))).toEqual([[TextAst, 'a']]); }); it('should parse elements with attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [AttrAst, 'a', 'b']]); }); }); it('should parse ngContent', () => { var parsed = parse('', []); - expect(humanizeTemplateAsts(parsed)).toEqual([[NgContentAst]]); + expect(humanizeTplAst(parsed)).toEqual([[NgContentAst]]); }); it('should parse bound text nodes', () => { - expect(humanizeTemplateAsts(parse('{{a}}', []))).toEqual([[BoundTextAst, '{{ a }}']]); + expect(humanizeTplAst(parse('{{a}}', []))).toEqual([[BoundTextAst, '{{ a }}']]); }); describe('bound properties', () => { + it('should parse mixed case bound properties', () => { + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Property, 'someProp', 'v', null] + ]); + }); + it('should parse and camel case bound properties', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'someProp', 'v', null] @@ -104,31 +109,71 @@ export function main() { }); it('should normalize property names via the element schema', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'mappedProp', 'v', null] ]); }); - it('should parse and camel case bound attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + it('should parse mixed case bound attributes', () => { + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Attribute, 'someAttr', 'v', null] ]); }); + it('should parse and camel case bound attributes', () => { + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Attribute, 'someAttr', 'v', null] + ]); + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Attribute, 'someAttr', 'v', null] + ]); + + }); + it('should parse and dash case bound classes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Class, 'some-class', 'v', null] + ]); + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Class, 'some-class', 'v', null] ]); }); + it('should parse mixed case bound classes', () => { + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Class, 'someClass', 'v', null] + ]); + }); + it('should parse and camel case bound styles', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Style, 'someStyle', 'v', null] + ]); + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Style, 'someStyle', 'v', null] + ]); + }); + + it('should parse and mixed case bound styles', () => { + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Style, 'someStyle', 'v', null] @@ -136,7 +181,7 @@ export function main() { }); it('should parse bound properties via [...] and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null] @@ -144,7 +189,12 @@ export function main() { }); it('should parse bound properties via bind- and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null] + ]); + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null] @@ -152,7 +202,7 @@ export function main() { }); it('should parse bound properties via {{...}} and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', '{{ v }}', null] @@ -164,22 +214,29 @@ export function main() { describe('events', () => { it('should parse bound events with a target', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [BoundEventAst, 'event', 'window', 'v']]); }); it('should parse bound events via (...) and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [BoundEventAst, 'event', null, 'v']]); }); - it('should camel case event names', () => { - expect(humanizeTemplateAsts(parse('
', []))) + it('should parse and camel case event names', () => { + expect(humanizeTplAst(parse('
', []))) + .toEqual([[ElementAst, 'div'], [BoundEventAst, 'someEvent', null, 'v']]); + }); + + it('should parse mixed case event names', () => { + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [BoundEventAst, 'someEvent', null, 'v']]); }); it('should parse bound events via on- and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([[ElementAst, 'div'], [BoundEventAst, 'event', null, 'v']]); + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [BoundEventAst, 'event', null, 'v']]); }); @@ -190,7 +247,7 @@ export function main() { outputs: ['e'], type: new CompileTypeMetadata({name: 'DirA'}) }); - expect(humanizeTemplateAsts(parse('', [dirA]))) + expect(humanizeTplAst(parse('', [dirA]))) .toEqual([ [EmbeddedTemplateAst], [BoundEventAst, 'e', null, 'f'], @@ -202,7 +259,7 @@ export function main() { describe('bindon', () => { it('should parse bound events and properties via [(...)] and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null], @@ -212,7 +269,13 @@ export function main() { it('should parse bound events and properties via bindon- and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([ + [ElementAst, 'div'], + [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null], + [BoundEventAst, 'propChange', null, 'v = $event'] + ]); + expect(humanizeTplAst(parse('
', []))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'prop', 'v', null], @@ -237,7 +300,7 @@ export function main() { type: new CompileTypeMetadata({name: 'ZComp'}), template: new CompileTemplateMetadata({ngContentSelectors: []}) }); - expect(humanizeTemplateAsts(parse('
', [dirA, dirB, dirC, comp]))) + expect(humanizeTplAst(parse('
', [dirA, dirB, dirC, comp]))) .toEqual([ [ElementAst, 'div'], [AttrAst, 'a', ''], @@ -255,7 +318,7 @@ export function main() { {selector: '[a=b]', type: new CompileTypeMetadata({name: 'DirA'})}); var dirB = CompileDirectiveMetadata.create( {selector: '[b]', type: new CompileTypeMetadata({name: 'DirB'})}); - expect(humanizeTemplateAsts(parse('
', [dirA, dirB]))) + expect(humanizeTplAst(parse('
', [dirA, dirB]))) .toEqual([ [ElementAst, 'div'], [BoundElementPropertyAst, PropertyBindingType.Property, 'a', 'b', null], @@ -269,7 +332,7 @@ export function main() { type: new CompileTypeMetadata({name: 'DirA'}), host: {'[a]': 'expr'} }); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [DirectiveAst, dirA], @@ -283,7 +346,7 @@ export function main() { type: new CompileTypeMetadata({name: 'DirA'}), host: {'(a)': 'expr'} }); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual( [[ElementAst, 'div'], [DirectiveAst, dirA], [BoundEventAst, 'a', null, 'expr']]); }); @@ -291,7 +354,7 @@ export function main() { it('should parse directive properties', () => { var dirA = CompileDirectiveMetadata.create( {selector: 'div', type: new CompileTypeMetadata({name: 'DirA'}), inputs: ['aProp']}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [DirectiveAst, dirA], @@ -302,7 +365,7 @@ export function main() { it('should parse renamed directive properties', () => { var dirA = CompileDirectiveMetadata.create( {selector: 'div', type: new CompileTypeMetadata({name: 'DirA'}), inputs: ['b:a']}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [DirectiveAst, dirA], @@ -313,7 +376,7 @@ export function main() { it('should parse literal directive properties', () => { var dirA = CompileDirectiveMetadata.create( {selector: 'div', type: new CompileTypeMetadata({name: 'DirA'}), inputs: ['a']}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [AttrAst, 'a', 'literal'], @@ -325,7 +388,7 @@ export function main() { it('should favor explicit bound properties over literal properties', () => { var dirA = CompileDirectiveMetadata.create( {selector: 'div', type: new CompileTypeMetadata({name: 'DirA'}), inputs: ['a']}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [AttrAst, 'a', 'literal'], @@ -337,7 +400,7 @@ export function main() { it('should support optional directive properties', () => { var dirA = CompileDirectiveMetadata.create( {selector: 'div', type: new CompileTypeMetadata({name: 'DirA'}), inputs: ['a']}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([[ElementAst, 'div'], [DirectiveAst, dirA]]); }); @@ -346,29 +409,31 @@ export function main() { describe('variables', () => { it('should parse variables via #... and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [VariableAst, 'a', '']]); }); it('should parse variables via var-... and not report them as attributes', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) + .toEqual([[ElementAst, 'div'], [VariableAst, 'a', '']]); + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [VariableAst, 'a', '']]); }); it('should camel case variables', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [VariableAst, 'someA', '']]); }); it('should assign variables with empty value to the element', () => { - expect(humanizeTemplateAsts(parse('
', []))) + expect(humanizeTplAst(parse('
', []))) .toEqual([[ElementAst, 'div'], [VariableAst, 'a', '']]); }); it('should assign variables to directives via exportAs', () => { var dirA = CompileDirectiveMetadata.create( {selector: '[a]', type: new CompileTypeMetadata({name: 'DirA'}), exportAs: 'dirA'}); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [AttrAst, 'a', ''], @@ -379,12 +444,12 @@ export function main() { it('should report variables with values that dont match a directive as errors', () => { expect(() => parse('
', [])).toThrowError(`Template parse errors: -There is no directive with "exportAs" set to "dirA" (
): TestComp@0:5`); +There is no directive with "exportAs" set to "dirA" ("
]#a="dirA">
"): TestComp@0:5`); }); it('should allow variables with values that dont match a directive on embedded template elements', () => { - expect(humanizeTemplateAsts(parse('', []))) + expect(humanizeTplAst(parse('', []))) .toEqual([[EmbeddedTemplateAst], [VariableAst, 'a', 'b']]); }); @@ -396,7 +461,7 @@ There is no directive with "exportAs" set to "dirA" (
): TestComp@ exportAs: 'dirA', template: new CompileTemplateMetadata({ngContentSelectors: []}) }); - expect(humanizeTemplateAsts(parse('
', [dirA]))) + expect(humanizeTplAst(parse('
', [dirA]))) .toEqual([ [ElementAst, 'div'], [AttrAst, 'a', ''], @@ -410,19 +475,23 @@ There is no directive with "exportAs" set to "dirA" (
): TestComp@ describe('explicit templates', () => { it('should create embedded templates for