/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import * as html from '../../src/ml_parser/ast'; import {HtmlParser} from '../../src/ml_parser/html_parser'; import {TokenizeOptions} from '../../src/ml_parser/lexer'; import {ParseTreeResult, TreeError} from '../../src/ml_parser/parser'; import {ParseError} from '../../src/parse_util'; import { humanizeDom, humanizeDomSourceSpans, humanizeLineColumn, humanizeNodes, } from './ast_spec_utils'; describe('HtmlParser', () => { let parser: HtmlParser; beforeEach(() => { parser = new HtmlParser(); }); describe('parse', () => { describe('text nodes', () => { it('should parse root level text nodes', () => { expect(humanizeDom(parser.parse('a', 'TestComp'))).toEqual([[html.Text, 'a', 0, ['a']]]); }); it('should parse text nodes inside regular elements', () => { expect(humanizeDom(parser.parse('
a
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Text, 'a', 1, ['a']], ]); }); it('should parse text nodes inside elements', () => { expect(humanizeDom(parser.parse('a', 'TestComp'))).toEqual([ [html.Element, 'ng-template', 0], [html.Text, 'a', 1, ['a']], ]); }); it('should parse CDATA', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Text, 'text', 0, ['text']], ]); }); it('should parse text nodes with HTML entities (5+ hex digits)', () => { // Test with 🛈 (U+1F6C8 - Circled Information Source) expect(humanizeDom(parser.parse('
🛈
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Text, '\u{1F6C8}', 1, [''], ['\u{1F6C8}', '🛈'], ['']], ]); }); it('should parse text nodes with decimal HTML entities (5+ digits)', () => { // Test with 🛈 (U+1F6C8 - Circled Information Source) as decimal 128712 expect(humanizeDom(parser.parse('
🛈
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Text, '\u{1F6C8}', 1, [''], ['\u{1F6C8}', '🛈'], ['']], ]); }); it('should parse named HTML entities containing digits', () => { expect(humanizeDom(parser.parse('
¹
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Text, '\u00B9', 1, [''], ['\u00B9', '¹'], ['']], ]); expect(humanizeDom(parser.parse('
½
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Text, '\u00BD', 1, [''], ['\u00BD', '½'], ['']], ]); }); it('should normalize line endings within CDATA', () => { const parsed = parser.parse('', 'TestComp'); expect(humanizeDom(parsed)).toEqual([ [html.Text, ' line 1 \n line 2 ', 0, [' line 1 \n line 2 ']], ]); expect(parsed.errors).toEqual([]); }); }); describe('elements', () => { it('should parse root level elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], ]); }); it('should parse elements inside of regular elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Element, 'span', 1], ]); }); it('should parse elements inside elements', () => { expect( humanizeDom(parser.parse('', 'TestComp')), ).toEqual([ [html.Element, 'ng-template', 0], [html.Element, 'span', 1], ]); }); it('should support void elements', () => { expect( humanizeDom(parser.parse('', 'TestComp')), ).toEqual([ [html.Element, 'link', 0], [html.Attribute, 'rel', 'author license', ['author license']], [html.Attribute, 'href', '/about', ['/about']], ]); }); it('should indicate whether an element is void', () => { const nodes = parser.parse('
', 'TestComp').rootNodes as html.Element[]; expect(nodes[0].name).toBe('input'); expect(nodes[0].isVoid).toBe(true); expect(nodes[1].name).toBe('div'); expect(nodes[1].isVoid).toBe(false); }); it('should not error on void elements from HTML5 spec', () => { // https://html.spec.whatwg.org/multipage/syntax.html#syntax-elements without: // - it can be present in head only // - it can be present in head only // - obsolete // - obsolete [ '', '

', '', '
', '

', '
', '
', '/', '', '', '

', ].forEach((html) => { expect(parser.parse(html, 'TestComp').errors).toEqual([]); }); }); it('should close void elements on text nodes', () => { expect(humanizeDom(parser.parse('

before
after

', 'TestComp'))).toEqual([ [html.Element, 'p', 0], [html.Text, 'before', 1, ['before']], [html.Element, 'br', 1], [html.Text, 'after', 1, ['after']], ]); }); it('should support optional end tags', () => { expect(humanizeDom(parser.parse('

1

2

', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Element, 'p', 1], [html.Text, '1', 2, ['1']], [html.Element, 'p', 1], [html.Text, '2', 2, ['2']], ]); }); it('should support nested elements', () => { expect( humanizeDom(parser.parse('
', 'TestComp')), ).toEqual([ [html.Element, 'ul', 0], [html.Element, 'li', 1], [html.Element, 'ul', 2], [html.Element, 'li', 3], ]); }); /** * Certain elements (like or ) require parent elements of a certain type (ex. * can only be inside / ). The Angular HTML parser doesn't validate those * HTML compliancy rules as "problematic" elements can be projected - in such case HTML (as * written in an Angular template) might be "invalid" (spec-wise) but the resulting DOM will * still be correct. */ it('should not wraps elements in a required parent', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Element, 'tr', 1], ]); }); it('should support explicit namespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, ':myns:div', 0], ]); }); it('should support implicit namespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, ':svg:svg', 0], ]); }); it('should propagate the namespace', () => { expect(humanizeDom(parser.parse('

', 'TestComp'))).toEqual([ [html.Element, ':myns:div', 0], [html.Element, ':myns:p', 1], ]); }); it('should match closing tags case sensitive', () => { const errors = parser.parse('

', 'TestComp').errors; expect(errors.length).toEqual(2); expect(humanizeErrors(errors)).toEqual([ [ 'p', 'Unexpected closing tag "p". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:8', ], [ 'dIv', 'Unexpected closing tag "dIv". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:12', ], ]); }); it('should support self closing void elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, 'input', 0, '#selfClosing'], ]); }); it('should support self closing foreign elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, ':math:math', 0, '#selfClosing'], ]); }); it('should ignore LF immediately after textarea, pre and listing', () => { expect( humanizeDom( parser.parse( '

\n

\n\n
\n\n', 'TestComp', ), ), ).toEqual([ [html.Element, 'p', 0], [html.Text, '\n', 1, ['\n']], [html.Element, 'textarea', 0], [html.Element, 'pre', 0], [html.Text, '\n', 1, ['\n']], [html.Element, 'listing', 0], [html.Text, '\n', 1, ['\n']], ]); }); it('should normalize line endings in text', () => { let parsed: ParseTreeResult; parsed = parser.parse(' line 1 \r\n line 2 ', 'TestComp'); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'title', 0], [html.Text, ' line 1 \n line 2 ', 1, [' line 1 \n line 2 ']], ]); expect(parsed.errors).toEqual([]); parsed = parser.parse('', 'TestComp'); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'script', 0], [html.Text, ' line 1 \n line 2 ', 1, [' line 1 \n line 2 ']], ]); expect(parsed.errors).toEqual([]); parsed = parser.parse('
line 1 \r\n line 2
', 'TestComp'); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, ' line 1 \n line 2 ', 1, [' line 1 \n line 2 ']], ]); expect(parsed.errors).toEqual([]); parsed = parser.parse(' line 1 \r\n line 2 ', 'TestComp'); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'span', 0], [html.Text, ' line 1 \n line 2 ', 1, [' line 1 \n line 2 ']], ]); expect(parsed.errors).toEqual([]); }); it('should parse element with JavaScript keyword tag name', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, 'constructor', 0], ]); }); }); describe('attributes', () => { it('should parse attributes on regular elements case sensitive', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'kEy', 'v', ['v']], [html.Attribute, 'key2', 'v2', ['v2']], ]); }); it('should parse attributes containing interpolation', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'foo', '1{{message}}2', ['1'], ['{{', 'message', '}}'], ['2']], ]); }); it('should parse attributes containing unquoted interpolation', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'foo', '{{message}}', [''], ['{{', 'message', '}}'], ['']], ]); }); it('should parse bound inputs with expressions containing newlines', () => { expect( humanizeDom( parser.parse( `` + ``, 'TestComp', ), ), ).toEqual([ [html.Element, 'app-component', 0], [ html.Attribute, '[attr]', `[\n {text: 'some text',url:'//www.google.com'},\n {text:'other text',url:'//www.google.com'}]`, [ `[\n {text: 'some text',url:'//www.google.com'},\n {text:'other text',url:'//www.google.com'}]`, ], ], ]); }); it('should parse attributes containing encoded entities', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'foo', '&', [''], ['&', '&'], ['']], ]); }); it('should parse attributes containing encoded entities (5+ hex digits)', () => { // Test with 🛈 (U+1F6C8 - Circled Information Source) expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'foo', '\u{1F6C8}', [''], ['\u{1F6C8}', '🛈'], ['']], ]); }); it('should parse attributes containing encoded decimal entities (5+ digits)', () => { // Test with 🛈 (U+1F6C8 - Circled Information Source) as decimal 128712 expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'foo', '\u{1F6C8}', [''], ['\u{1F6C8}', '🛈'], ['']], ]); }); it('should decode HTML entities in interpolated attributes', () => { // Note that the detail of decoding corner-cases is tested in the // "should decode HTML entities in interpolations" spec. expect( humanizeDomSourceSpans(parser.parse('
', 'TestComp')), ).toEqual([ [ html.Element, 'div', 0, '
', '
', '
', ], [html.Attribute, 'foo', '{{&}}', [''], ['{{', '&', '}}'], [''], 'foo="{{&}}"'], ]); }); it('should normalize line endings within attribute values', () => { const result = parser.parse('
', 'TestComp'); expect(humanizeDom(result)).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'key', ' \n line 1 \n line 2 ', [' \n line 1 \n line 2 ']], ]); expect(result.errors).toEqual([]); }); it('should parse attributes without values', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'k', ''], ]); }); it('should parse attributes on svg elements case sensitive', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, ':svg:svg', 0], [html.Attribute, 'viewBox', '0', ['0']], ]); }); it('should parse attributes on elements', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, 'ng-template', 0], [html.Attribute, 'k', 'v', ['v']], ]); }); it('should support namespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Element, ':svg:use', 0, '#selfClosing'], [html.Attribute, ':xlink:href', 'Port', ['Port']], ]); }); it('should support a prematurely terminated interpolation in attribute', () => { const {errors, rootNodes} = parser.parse('
', 'TestComp'); expect(humanizeNodes(rootNodes, true)).toEqual([ [html.Element, 'div', 0, '
', '
', null], [html.Attribute, 'p', '{{ abc', [''], ['{{', ' abc'], [''], 'p="{{ abc"'], [html.Element, 'span', 1, '', '', ''], ]); expect(humanizeErrors(errors)).toEqual([]); }); describe('animate instructions', () => { it('should parse animate.enter as a static attribute', () => { expect(humanizeDom(parser.parse(`
`, 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'animate.enter', 'foo', ['foo']], ]); }); it('should parse animate.leave as a static attribute', () => { expect(humanizeDom(parser.parse(`
`, 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'animate.leave', 'bar', ['bar']], ]); }); it('should not parse any other animate prefix binding as animate.leave', () => { expect(humanizeDom(parser.parse(`
`, 'TestComp'))).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'animateAbc', 'bar', ['bar']], ]); }); it('should parse both animate.enter and animate.leave as static attributes', () => { expect( humanizeDom( parser.parse(`
`, 'TestComp'), ), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'animate.enter', 'foo', ['foo']], [html.Attribute, 'animate.leave', 'bar', ['bar']], ]); }); it('should parse animate.enter as a property binding', () => { expect( humanizeDom(parser.parse(`
`, 'TestComp')), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '[animate.enter]', `'foo'`, [`'foo'`]], ]); }); it('should parse animate.leave as a property binding with a string array', () => { expect( humanizeDom(parser.parse(`
`, 'TestComp')), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '[animate.leave]', `['bar', 'baz']`, [`['bar', 'baz']`]], ]); }); it('should parse animate.enter as an event binding', () => { expect( humanizeDom( parser.parse(`
`, 'TestComp'), ), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '(animate.enter)', 'onAnimation($event)', ['onAnimation($event)']], ]); }); it('should parse animate.leave as an event binding', () => { expect( humanizeDom( parser.parse(`
`, 'TestComp'), ), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '(animate.leave)', 'onAnimation($event)', ['onAnimation($event)']], ]); }); it('should not parse other animate prefixes as animate.leave', () => { expect( humanizeDom(parser.parse(`
`, 'TestComp')), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '(animateXYZ)', 'onAnimation()', ['onAnimation()']], ]); }); it('should parse a combination of animate property and event bindings', () => { expect( humanizeDom( parser.parse( `
`, 'TestComp', ), ), ).toEqual([ [html.Element, 'div', 0], [html.Attribute, '[animate.enter]', `'foo'`, [`'foo'`]], [html.Attribute, '(animate.leave)', 'onAnimation($event)', ['onAnimation($event)']], ]); }); }); it('should parse square-bracketed attributes more permissively', () => { expect( humanizeDom( parser.parse( ``, 'TestComp', ), ), ).toEqual([ [html.Element, 'foo', 0, '#selfClosing'], [html.Attribute, '[class.text-primary/80]', 'expr', ['expr']], [html.Attribute, '[class.data-active:text-green-300/80]', 'expr2', ['expr2']], [html.Attribute, "[class.data-[size='large']:p-8]", 'expr3', ['expr3']], [html.Attribute, 'some-attr', ''], ]); }); }); describe('comments', () => { it('should preserve comments', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))).toEqual([ [html.Comment, 'comment', 0], [html.Element, 'div', 0], ]); }); it('should normalize line endings within comments', () => { expect(humanizeDom(parser.parse('', 'TestComp'))).toEqual([ [html.Comment, 'line 1 \n line 2', 0], ]); }); }); describe('expansion forms', () => { it('should parse out expansion forms (with multiple cases)', () => { const parsed = parser.parse( `
before{messages.length, plural, =0 {You have no messages} =1 {One {{message}}}}after
`, 'TestComp', {tokenizeExpansionForms: true}, ); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, 'before', 1, ['before']], [html.Expansion, 'messages.length', 'plural', 1], [html.ExpansionCase, '=0', 2], [html.ExpansionCase, '=1', 2], [html.Text, 'after', 1, ['after']], ]); const cases = (parsed.rootNodes[0]).children[1].cases; expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ [html.Text, 'You have ', 0, ['You have ']], [html.Element, 'b', 0], [html.Text, 'no', 1, ['no']], [html.Text, ' messages', 0, [' messages']], ]); expect(humanizeDom(new ParseTreeResult(cases[1].expression, []))).toEqual([ [html.Text, 'One {{message}}', 0, ['One '], ['{{', 'message', '}}'], ['']], ]); }); it('should normalize line-endings in expansion forms in inline templates if `i18nNormalizeLineEndingsInICUs` is true', () => { const parsed = parser.parse( `
\r\n` + ` {\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n` + `
`, 'TestComp', { tokenizeExpansionForms: true, escapedString: true, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, '\n ', 1, ['\n ']], [html.Expansion, '\n messages.length', 'plural', 1], [html.ExpansionCase, '=0', 2], [html.ExpansionCase, '=1', 2], [html.Text, '\n', 1, ['\n']], ]); const cases = (parsed.rootNodes[0]).children[1].cases; expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ [html.Text, 'You have \nno\n messages', 0, ['You have \nno\n messages']], ]); expect(humanizeDom(new ParseTreeResult(cases[1].expression, []))).toEqual([ [html.Text, 'One {{message}}', 0, ['One '], ['{{', 'message', '}}'], ['']], ]); expect(parsed.errors).toEqual([]); }); it('should not normalize line-endings in ICU expressions in external templates when `i18nNormalizeLineEndingsInICUs` is not set', () => { const parsed = parser.parse( `
\r\n` + ` {\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n` + `
`, 'TestComp', {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, '\n ', 1, ['\n ']], [html.Expansion, '\r\n messages.length', 'plural', 1], [html.ExpansionCase, '=0', 2], [html.ExpansionCase, '=1', 2], [html.Text, '\n', 1, ['\n']], ]); const cases = (parsed.rootNodes[0]).children[1].cases; expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ [html.Text, 'You have \nno\n messages', 0, ['You have \nno\n messages']], ]); expect(humanizeDom(new ParseTreeResult(cases[1].expression, []))).toEqual([ [html.Text, 'One {{message}}', 0, ['One '], ['{{', 'message', '}}'], ['']], ]); expect(parsed.errors).toEqual([]); }); it('should normalize line-endings in expansion forms in external templates if `i18nNormalizeLineEndingsInICUs` is true', () => { const parsed = parser.parse( `
\r\n` + ` {\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n` + `
`, 'TestComp', { tokenizeExpansionForms: true, escapedString: false, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, '\n ', 1, ['\n ']], [html.Expansion, '\n messages.length', 'plural', 1], [html.ExpansionCase, '=0', 2], [html.ExpansionCase, '=1', 2], [html.Text, '\n', 1, ['\n']], ]); const cases = (parsed.rootNodes[0]).children[1].cases; expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ [html.Text, 'You have \nno\n messages', 0, ['You have \nno\n messages']], ]); expect(humanizeDom(new ParseTreeResult(cases[1].expression, []))).toEqual([ [html.Text, 'One {{message}}', 0, ['One '], ['{{', 'message', '}}'], ['']], ]); expect(parsed.errors).toEqual([]); }); it('should not normalize line-endings in ICU expressions in external templates when `i18nNormalizeLineEndingsInICUs` is not set (escapedString:false)', () => { const parsed = parser.parse( `
\r\n` + ` {\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n` + `
`, 'TestComp', {tokenizeExpansionForms: true, escapedString: false}, ); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Text, '\n ', 1, ['\n ']], [html.Expansion, '\r\n messages.length', 'plural', 1], [html.ExpansionCase, '=0', 2], [html.ExpansionCase, '=1', 2], [html.Text, '\n', 1, ['\n']], ]); const cases = (parsed.rootNodes[0]).children[1].cases; expect(humanizeDom(new ParseTreeResult(cases[0].expression, []))).toEqual([ [html.Text, 'You have \nno\n messages', 0, ['You have \nno\n messages']], ]); expect(humanizeDom(new ParseTreeResult(cases[1].expression, []))).toEqual([ [html.Text, 'One {{message}}', 0, ['One '], ['{{', 'message', '}}'], ['']], ]); expect(parsed.errors).toEqual([]); }); it('should parse out expansion forms', () => { const parsed = parser.parse(`
{a, plural, =0 {b}}
`, 'TestComp', { tokenizeExpansionForms: true, }); expect(humanizeDom(parsed)).toEqual([ [html.Element, 'div', 0], [html.Element, 'span', 1], [html.Expansion, 'a', 'plural', 2], [html.ExpansionCase, '=0', 3], ]); }); it('should parse out nested expansion forms', () => { const parsed = parser.parse( `{messages.length, plural, =0 { {p.gender, select, male {m}} }}`, 'TestComp', {tokenizeExpansionForms: true}, ); expect(humanizeDom(parsed)).toEqual([ [html.Expansion, 'messages.length', 'plural', 0], [html.ExpansionCase, '=0', 1], ]); const firstCase = (parsed.rootNodes[0]).cases[0]; expect(humanizeDom(new ParseTreeResult(firstCase.expression, []))).toEqual([ [html.Expansion, 'p.gender', 'select', 0], [html.ExpansionCase, 'male', 1], [html.Text, ' ', 0, [' ']], ]); }); it('should normalize line endings in nested expansion forms for inline templates, when `i18nNormalizeLineEndingsInICUs` is true', () => { const parsed = parser.parse( `{\r\n` + ` messages.length, plural,\r\n` + ` =0 { zero \r\n` + ` {\r\n` + ` p.gender, select,\r\n` + ` male {m}\r\n` + ` }\r\n` + ` }\r\n` + `}`, 'TestComp', { tokenizeExpansionForms: true, escapedString: true, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeDom(parsed)).toEqual([ [html.Expansion, '\n messages.length', 'plural', 0], [html.ExpansionCase, '=0', 1], ]); const expansion = parsed.rootNodes[0] as html.Expansion; expect(humanizeDom(new ParseTreeResult(expansion.cases[0].expression, []))).toEqual([ [html.Text, 'zero \n ', 0, ['zero \n ']], [html.Expansion, '\n p.gender', 'select', 0], [html.ExpansionCase, 'male', 1], [html.Text, '\n ', 0, ['\n ']], ]); expect(parsed.errors).toEqual([]); }); it('should not normalize line endings in nested expansion forms for inline templates, when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const parsed = parser.parse( `{\r\n` + ` messages.length, plural,\r\n` + ` =0 { zero \r\n` + ` {\r\n` + ` p.gender, select,\r\n` + ` male {m}\r\n` + ` }\r\n` + ` }\r\n` + `}`, 'TestComp', {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeDom(parsed)).toEqual([ [html.Expansion, '\r\n messages.length', 'plural', 0], [html.ExpansionCase, '=0', 1], ]); const expansion = parsed.rootNodes[0] as html.Expansion; expect(humanizeDom(new ParseTreeResult(expansion.cases[0].expression, []))).toEqual([ [html.Text, 'zero \n ', 0, ['zero \n ']], [html.Expansion, '\r\n p.gender', 'select', 0], [html.ExpansionCase, 'male', 1], [html.Text, '\n ', 0, ['\n ']], ]); expect(parsed.errors).toEqual([]); }); it('should not normalize line endings in nested expansion forms for external templates, when `i18nNormalizeLineEndingsInICUs` is not set', () => { const parsed = parser.parse( `{\r\n` + ` messages.length, plural,\r\n` + ` =0 { zero \r\n` + ` {\r\n` + ` p.gender, select,\r\n` + ` male {m}\r\n` + ` }\r\n` + ` }\r\n` + `}`, 'TestComp', {tokenizeExpansionForms: true}, ); expect(humanizeDom(parsed)).toEqual([ [html.Expansion, '\r\n messages.length', 'plural', 0], [html.ExpansionCase, '=0', 1], ]); const expansion = parsed.rootNodes[0] as html.Expansion; expect(humanizeDom(new ParseTreeResult(expansion.cases[0].expression, []))).toEqual([ [html.Text, 'zero \n ', 0, ['zero \n ']], [html.Expansion, '\r\n p.gender', 'select', 0], [html.ExpansionCase, 'male', 1], [html.Text, '\n ', 0, ['\n ']], ]); expect(parsed.errors).toEqual([]); }); it('should error when expansion form is not closed', () => { const p = parser.parse(`{messages.length, plural, =0 {one}`, 'TestComp', { tokenizeExpansionForms: true, }); expect(humanizeErrors(p.errors)).toEqual([ [null, "Invalid ICU message. Missing '}'.", '0:34'], ]); }); it('should support ICU expressions with cases that contain numbers', () => { const p = parser.parse(`{sex, select, male {m} female {f} 0 {other}}`, 'TestComp', { tokenizeExpansionForms: true, }); expect(p.errors.length).toEqual(0); }); it(`should support ICU expressions with cases that contain any character except '}'`, () => { const p = parser.parse(`{a, select, b {foo} % bar {% bar}}`, 'TestComp', { tokenizeExpansionForms: true, }); expect(p.errors.length).toEqual(0); }); it('should error when expansion case is not properly closed', () => { const p = parser.parse(`{a, select, b {foo} % { bar {% bar}}`, 'TestComp', { tokenizeExpansionForms: true, }); expect(humanizeErrors(p.errors)).toEqual([ [ 'Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ \'{\' }}") to escape it.)', '0:36', ], [null, "Invalid ICU message. Missing '}'.", '0:22'], ]); }); it('should error when expansion case is not closed', () => { const p = parser.parse(`{messages.length, plural, =0 {one`, 'TestComp', { tokenizeExpansionForms: true, }); expect(humanizeErrors(p.errors)).toEqual([ [null, "Invalid ICU message. Missing '}'.", '0:29'], ]); }); it('should error when invalid html in the case', () => { const p = parser.parse(`{messages.length, plural, =0 {}`, 'TestComp', { tokenizeExpansionForms: true, }); expect(humanizeErrors(p.errors)).toEqual([ ['b', 'Only void, custom and foreign elements can be self closed "b"', '0:30'], ]); }); }); describe('blocks', () => { it('should parse a block', () => { expect(humanizeDom(parser.parse('@defer (a b; c d){hello}', 'TestComp'))).toEqual([ [html.Block, 'defer', 0], [html.BlockParameter, 'a b'], [html.BlockParameter, 'c d'], [html.Text, 'hello', 1, ['hello']], ]); }); it('should parse a block with an HTML element', () => { expect(humanizeDom(parser.parse('@defer {}', 'TestComp'))).toEqual([ [html.Block, 'defer', 0], [html.Element, 'my-cmp', 1, '#selfClosing'], ]); }); it('should parse a block containing mixed plain text and HTML', () => { expect( humanizeDom( parser.parse( '@switch (expr) {' + '@case (1) {hellothere}' + '@case (two) {

Two...

}' + '@case (isThree(3)) {Thtree!}' + '}', 'TestComp', ), ), ).toEqual([ [html.Block, 'switch', 0], [html.BlockParameter, 'expr'], [html.Block, 'case', 1], [html.BlockParameter, '1'], [html.Text, 'hello', 2, ['hello']], [html.Element, 'my-cmp', 2, '#selfClosing'], [html.Text, 'there', 2, ['there']], [html.Block, 'case', 1], [html.BlockParameter, 'two'], [html.Element, 'p', 2], [html.Text, 'Two...', 3, ['Two...']], [html.Block, 'case', 1], [html.BlockParameter, 'isThree(3)'], [html.Text, 'T', 2, ['T']], [html.Element, 'strong', 2], [html.Text, 'htr', 3, ['htr']], [html.Element, 'i', 3], [html.Text, 'e', 4, ['e']], [html.Text, 'e', 3, ['e']], [html.Text, '!', 2, ['!']], ]); }); it('should parse nested blocks', () => { const markup = `` + `@if (root) {` + `` + `` + `@if (childParam === 1) {` + `@if (innerChild1 === foo) {` + `` + `@switch (grandChild) {` + `@case (innerGrandChild) {` + `` + `}` + `@case (innerGrandChild) {` + `` + `}` + `}` + `}` + `@if (innerChild) {` + `` + `}` + `}` + `` + `@for (outerChild1; outerChild2) {` + `` + `}` + `} `; expect(humanizeDom(parser.parse(markup, 'TestComp'))).toEqual([ [html.Element, 'root-sibling-one', 0, '#selfClosing'], [html.Block, 'if', 0], [html.BlockParameter, 'root'], [html.Element, 'outer-child-one', 1, '#selfClosing'], [html.Element, 'outer-child-two', 1], [html.Block, 'if', 2], [html.BlockParameter, 'childParam === 1'], [html.Block, 'if', 3], [html.BlockParameter, 'innerChild1 === foo'], [html.Element, 'inner-child-one', 4, '#selfClosing'], [html.Block, 'switch', 4], [html.BlockParameter, 'grandChild'], [html.Block, 'case', 5], [html.BlockParameter, 'innerGrandChild'], [html.Element, 'inner-grand-child-one', 6, '#selfClosing'], [html.Block, 'case', 5], [html.BlockParameter, 'innerGrandChild'], [html.Element, 'inner-grand-child-two', 6, '#selfClosing'], [html.Block, 'if', 3], [html.BlockParameter, 'innerChild'], [html.Element, 'inner-child-two', 4, '#selfClosing'], [html.Block, 'for', 1], [html.BlockParameter, 'outerChild1'], [html.BlockParameter, 'outerChild2'], [html.Element, 'outer-child-three', 2, '#selfClosing'], [html.Text, ' ', 0, [' ']], [html.Element, 'root-sibling-two', 0, '#selfClosing'], ]); }); it('should infer namespace through block boundary', () => { expect(humanizeDom(parser.parse('@if (cond) {}', 'TestComp'))).toEqual([ [html.Element, ':svg:svg', 0], [html.Block, 'if', 1], [html.BlockParameter, 'cond'], [html.Element, ':svg:circle', 2, '#selfClosing'], ]); }); it('should parse an empty block', () => { expect(humanizeDom(parser.parse('@defer{}', 'TestComp'))).toEqual([ [html.Block, 'defer', 0], ]); }); it('should parse a block with void elements', () => { expect(humanizeDom(parser.parse('@defer {
}', 'TestComp'))).toEqual([ [html.Block, 'defer', 0], [html.Element, 'br', 1], ]); }); it('should parse consecutive @case statements', () => { expect( humanizeDom( parser.parse(`@switch (expr) {@case ('foo') @case ('bar') { }}`, `TestComp`), ), ).toEqual([ [html.Block, 'switch', 0], [html.BlockParameter, 'expr'], [html.Block, 'case', 1], [html.BlockParameter, `'foo'`], [html.Block, 'case', 1], [html.BlockParameter, `'bar'`], [html.Text, ' ', 2, [' ']], [html.Element, 'input', 2], [html.Text, ' ', 2, [' ']], ]); }); it('should parse empty cases in a switch block', () => { expect( humanizeDom( parser.parse( `@switch (expr) {@case ('foo') {} @case ('bar') {bar} @case('baz') { baz }}`, `TestComp`, ), ), ).toEqual([ [html.Block, 'switch', 0], [html.BlockParameter, 'expr'], [html.Block, 'case', 1], [html.BlockParameter, `'foo'`], [html.Text, ' ', 1, [' ']], [html.Block, 'case', 1], [html.BlockParameter, `'bar'`], [html.Text, 'bar', 2, ['bar']], [html.Text, ' ', 1, [' ']], [html.Block, 'case', 1], [html.BlockParameter, `'baz'`], [html.Text, ' baz ', 2, [' baz ']], ]); }); it('should parse exhaustive default checks in a switch block', () => { expect( humanizeDom( parser.parse(`@switch (expr) {@case ('foo') {} @default never;}`, `TestComp`), ), ).toEqual([ [html.Block, 'switch', 0], [html.BlockParameter, 'expr'], [html.Block, 'case', 1], [html.BlockParameter, `'foo'`], [html.Text, ' ', 1, [' ']], [html.Block, 'default never', 1], ]); }); it('should close void elements used right before a block', () => { expect(humanizeDom(parser.parse('@defer {hello}', 'TestComp'))).toEqual([ [html.Element, 'img', 0], [html.Block, 'defer', 0], [html.Text, 'hello', 1, ['hello']], ]); }); it('should report an unclosed block', () => { const errors = parser.parse('@defer {hello', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([['defer', 'Unclosed block "defer"', '0:0']]); }); it('should report an unexpected block close', () => { const errors = parser.parse('hello}', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ [ null, 'Unexpected closing block. The block may have been closed earlier. If you meant to write the `}` character, you should use the "}" HTML entity instead.', '0:5', ], ]); }); it('should report unclosed tags inside of a block', () => { const errors = parser.parse('@defer {hello}', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ [ null, 'Unexpected closing block. The block may have been closed earlier. Did you forget to close the element? If you meant to write the `}` character, you should use the "}" HTML entity instead.', '0:21', ], ]); }); it('should report an unexpected closing tag inside a block', () => { const errors = parser.parse('
@if (cond) {hello
}', 'TestComp').errors; expect(errors.length).toEqual(2); expect(humanizeErrors(errors)).toEqual([ [ 'div', 'Unexpected closing tag "div". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:22', ], [ null, 'Unexpected closing block. The block may have been closed earlier. If you meant to write the `}` character, you should use the "}" HTML entity instead.', '0:28', ], ]); }); it('should report a final @case without a body', () => { const errors = parser.parse('@switch (expr) {@case (1)}', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ [ 'case', 'Incomplete block "case". If you meant to write the @ character, you should use the "@" HTML entity instead.', '0:16', ], ]); }); it('should store the source locations of blocks', () => { const markup = '@switch (expr) {' + '@case (1) {
hello
world}' + '@case (two) {Two}' + '@case (isThree(3)) {Placeholder}' + '}'; expect(humanizeDomSourceSpans(parser.parse(markup, 'TestComp'))).toEqual([ [ html.Block, 'switch', 0, '@switch (expr) {@case (1) {
hello
world}@case (two) {Two}@case (isThree(3)) {Placeholder}}', '@switch (expr) {', '}', ], [html.BlockParameter, 'expr', 'expr'], [html.Block, 'case', 1, '@case (1) {
hello
world}', '@case (1) {', '}'], [html.BlockParameter, '1', '1'], [html.Element, 'div', 2, '
hello
', '
', '
'], [html.Text, 'hello', 3, ['hello'], 'hello'], [html.Text, 'world', 2, ['world'], 'world'], [html.Block, 'case', 1, '@case (two) {Two}', '@case (two) {', '}'], [html.BlockParameter, 'two', 'two'], [html.Text, 'Two', 2, ['Two'], 'Two'], [ html.Block, 'case', 1, '@case (isThree(3)) {Placeholder}', '@case (isThree(3)) {', '}', ], [html.BlockParameter, 'isThree(3)', 'isThree(3)'], [html.Text, 'Placeholde', 2, ['Placeholde'], 'Placeholde'], [html.Element, 'strong', 2, 'r', '', ''], [html.Text, 'r', 3, ['r'], 'r'], ]); }); it('should parse an incomplete block with no parameters', () => { const result = parser.parse('This is the @if() block', 'TestComp'); expect(humanizeNodes(result.rootNodes, true)).toEqual([ [html.Text, 'This is the ', 0, ['This is the '], 'This is the '], [html.Block, 'if', 0, '@if() ', '@if() ', null], [html.Text, 'block', 0, ['block'], 'block'], ]); expect(humanizeErrors(result.errors)).toEqual([ [ 'if', 'Incomplete block "if". If you meant to write the @ character, you should use the "@" HTML entity instead.', '0:12', ], ]); }); it('should parse an incomplete block with no body', () => { const result = parser.parse( 'This is the @if({alias: "foo"}) block with params', 'TestComp', ); expect(humanizeNodes(result.rootNodes, true)).toEqual([ [html.Text, 'This is the ', 0, ['This is the '], 'This is the '], [html.Block, 'if', 0, '@if({alias: "foo"}) ', '@if({alias: "foo"}) ', null], [html.BlockParameter, '{alias: "foo"}', '{alias: "foo"}'], [html.Text, 'block with params', 0, ['block with params'], 'block with params'], ]); expect(humanizeErrors(result.errors)).toEqual([ [ 'if', 'Incomplete block "if". If you meant to write the @ character, you should use the "@" HTML entity instead.', '0:12', ], ]); }); }); describe('let declaration', () => { it('should parse a let declaration', () => { expect(humanizeDom(parser.parse('@let foo = 123;', 'TestCmp'))).toEqual([ [html.LetDeclaration, 'foo', '123'], ]); }); it('should parse a let declaration that is nested in a parent', () => { expect( humanizeDom(parser.parse('@defer {@if (true) {@let foo = 123;}}', 'TestCmp')), ).toEqual([ [html.Block, 'defer', 0], [html.Block, 'if', 1], [html.BlockParameter, 'true'], [html.LetDeclaration, 'foo', '123'], ]); }); it('should store the source location of a @let declaration', () => { expect(humanizeDomSourceSpans(parser.parse('@let foo = 123 + 456;', 'TestCmp'))).toEqual([ [html.LetDeclaration, 'foo', '123 + 456', '@let foo = 123 + 456', 'foo', '123 + 456'], ]); }); it('should report an error for an incomplete let declaration', () => { expect(humanizeErrors(parser.parse('@let foo =', 'TestCmp').errors)).toEqual([ [ 'foo', 'Incomplete @let declaration "foo". @let declarations must be written as `@let = ;`', '0:0', ], ]); }); it('should store the locations of an incomplete let declaration', () => { const parseResult = parser.parse('@let foo =', 'TestCmp'); // It's expected that errors will be reported for the incomplete declaration, // but we still want to check the spans since they're important even for broken templates. parseResult.errors = []; expect(humanizeDomSourceSpans(parseResult)).toEqual([ [html.LetDeclaration, 'foo', '', '@let foo =', 'foo =', ''], ]); }); }); describe('directive nodes', () => { const options: TokenizeOptions = { selectorlessEnabled: true, }; it('should parse a directive with no attributes', () => { const parsed = humanizeDom(parser.parse('
', '', options)); expect(parsed).toEqual([ [html.Element, 'div', 0], [html.Directive, 'Dir'], ]); }); it('should parse a directive with attributes', () => { const parsed = humanizeDom( parser.parse('
', '', options), ); expect(parsed).toEqual([ [html.Element, 'div', 0], [html.Directive, 'Dir'], [html.Attribute, 'a', '1', ['1']], [html.Attribute, '[b]', 'two', ['two']], [html.Attribute, '(c)', 'c()', ['c()']], ]); }); it('should parse directives on a component node', () => { const parsed = humanizeDom( parser.parse('', '', options), ); expect(parsed).toEqual([ [html.Component, 'MyComp', null, 'MyComp', 0], [html.Directive, 'Dir'], [html.Directive, 'OtherDir'], [html.Attribute, 'a', '1', ['1']], [html.Attribute, '[b]', 'two', ['two']], [html.Attribute, '(c)', 'c()', ['c()']], ]); }); it('should report a missing directive closing paren', () => { expect( humanizeErrors(parser.parse('
', '', options).errors), ).toEqual([[null, 'Unterminated directive definition', '0:5']]); expect( humanizeErrors(parser.parse('', '', options).errors), ).toEqual([[null, 'Unterminated directive definition', '0:8']]); }); it('should parse a directive mixed with other attributes', () => { const parsed = humanizeDom( parser.parse( '
', '', options, ), ); expect(parsed).toEqual([ [html.Element, 'div', 0], [html.Attribute, 'before', 'foo', ['foo']], [html.Attribute, 'middle', ''], [html.Attribute, 'after', '123', ['123']], [html.Directive, 'Dir'], [html.Directive, 'OtherDir'], [html.Attribute, '[a]', 'a', ['a']], [html.Attribute, '(b)', 'b()', ['b()']], ]); }); it('should store the source locations of directives', () => { const markup = '
'; expect(humanizeDomSourceSpans(parser.parse(markup, '', options))).toEqual([ [ html.Element, 'div', 0, '
', '
', '
', ], [html.Directive, 'Dir', '@Dir', '@Dir', null], [html.Directive, 'OtherDir', '@OtherDir(a="1" [b]="two" (c)="c()")', '@OtherDir(', ')'], [html.Attribute, 'a', '1', ['1'], 'a="1"'], [html.Attribute, '[b]', 'two', ['two'], '[b]="two"'], [html.Attribute, '(c)', 'c()', ['c()'], '(c)="c()"'], ]); }); }); describe('component nodes', () => { const options: TokenizeOptions = { selectorlessEnabled: true, }; it('should parse a simple component node', () => { const parsed = humanizeDom(parser.parse('Hello', '', options)); expect(parsed).toEqual([ [html.Component, 'MyComp', null, 'MyComp', 0], [html.Text, 'Hello', 1, ['Hello']], ]); }); it('should parse a self-closing component node', () => { const parsed = humanizeDom(parser.parse('Hello', '', options)); expect(parsed).toEqual([ [html.Component, 'MyComp', null, 'MyComp', 0, '#selfClosing'], [html.Text, 'Hello', 0, ['Hello']], ]); }); it('should parse a component node with a tag name', () => { const parsed = humanizeDom( parser.parse('Hello', '', options), ); expect(parsed).toEqual([ [html.Component, 'MyComp', 'button', 'MyComp:button', 0], [html.Text, 'Hello', 1, ['Hello']], ]); }); it('should parse a component node with a tag name and namespace', () => { const parsed = humanizeDom( parser.parse('Hello', '', options), ); expect(parsed).toEqual([ [html.Component, 'MyComp', ':svg:title', 'MyComp:svg:title', 0], [html.Text, 'Hello', 1, ['Hello']], ]); }); it('should parse a component node with an inferred namespace and no tag name', () => { const parsed = humanizeDom(parser.parse('Hello', '', options)); expect(parsed).toEqual([ [html.Element, ':svg:svg', 0], [html.Component, 'MyComp', ':svg:ng-component', 'MyComp:svg:ng-component', 1], [html.Text, 'Hello', 2, ['Hello']], ]); }); it('should parse a component node with an inferred namespace and a tag name', () => { const parsed = humanizeDom( parser.parse('Hello', '', options), ); expect(parsed).toEqual([ [html.Element, ':svg:svg', 0], [html.Component, 'MyComp', ':svg:button', 'MyComp:svg:button', 1], [html.Text, 'Hello', 2, ['Hello']], ]); }); it('should parse a component node with an inferred namespace plus an explicit namespace and tag name', () => { const parsed = humanizeDom( parser.parse('Hello', '', options), ); expect(parsed).toEqual([ [html.Element, ':math:math', 0], [html.Component, 'MyComp', ':svg:title', 'MyComp:svg:title', 1], [html.Text, 'Hello', 2, ['Hello']], ]); }); it('should distinguish components with tag names from ones without', () => { const parsed = humanizeDom( parser.parse('Hello', '', options), ); expect(parsed).toEqual([ [html.Component, 'MyComp', 'button', 'MyComp:button', 0], [html.Component, 'MyComp', null, 'MyComp', 1], [html.Text, 'Hello', 2, ['Hello']], ]); }); it('should implicitly close a component', () => { const parsed = humanizeDom(parser.parse('Hello', '', options)); expect(parsed).toEqual([ [html.Component, 'MyComp', null, 'MyComp', 0], [html.Text, 'Hello', 1, ['Hello']], ]); }); it('should parse a component tag nested within other markup', () => { const parsed = humanizeDom( parser.parse( '@if (expr) {
Hello:
}', '', options, ), ); expect(parsed).toEqual([ [html.Block, 'if', 0], [html.BlockParameter, 'expr'], [html.Element, 'div', 1], [html.Text, 'Hello: ', 2, ['Hello: ']], [html.Component, 'MyComp', null, 'MyComp', 2], [html.Element, 'span', 3], [html.Component, 'OtherComp', null, 'OtherComp', 4, '#selfClosing'], ]); }); it('should report closing tag whose tag name does not match the opening tag', () => { expect( humanizeErrors(parser.parse('Hello
', '', options).errors), ).toEqual([ ['MyComp', 'Unexpected closing tag "MyComp", did you mean "MyComp:button"?', '0:20'], ]); expect( humanizeErrors(parser.parse('Hello', '', options).errors), ).toEqual([ [ 'MyComp:button', 'Unexpected closing tag "MyComp:button", did you mean "MyComp"?', '0:13', ], ]); }); it('should parse a component node with attributes and directives', () => { const parsed = humanizeDom( parser.parse( 'Hello', '', options, ), ); expect(parsed).toEqual([ [html.Component, 'MyComp', null, 'MyComp', 0], [html.Attribute, 'before', 'foo', ['foo']], [html.Attribute, 'middle', ''], [html.Attribute, 'after', '123', ['123']], [html.Directive, 'Dir'], [html.Directive, 'OtherDir'], [html.Attribute, '[a]', 'a', ['a']], [html.Attribute, '(b)', 'b()', ['b()']], [html.Text, 'Hello', 1, ['Hello']], ]); }); it('should store the source locations of a component with attributes and content', () => { const markup = 'Hello'; expect(humanizeDomSourceSpans(parser.parse(markup, '', options))).toEqual([ [ html.Component, 'MyComp', null, 'MyComp', 0, 'Hello', '', '', ], [html.Attribute, 'one', '1', ['1'], 'one="1"'], [html.Attribute, 'two', '', 'two'], [html.Attribute, '[three]', '3', ['3'], '[three]="3"'], [html.Text, 'Hello', 1, ['Hello'], 'Hello'], ]); }); it('should store the source locations of self-closing components', () => { const markup = 'Hello'; expect(humanizeDomSourceSpans(parser.parse(markup, '', options))).toEqual([ [ html.Component, 'MyComp', null, 'MyComp', 0, '', '#selfClosing', '', '', ], [html.Attribute, 'one', '1', ['1'], 'one="1"'], [html.Attribute, 'two', '', 'two'], [html.Attribute, '[three]', '3', ['3'], '[three]="3"'], [html.Text, 'Hello', 0, ['Hello'], 'Hello'], [ html.Component, 'MyOtherComp', null, 'MyOtherComp', 0, '', '#selfClosing', '', '', ], [ html.Component, 'MyThirdComp', 'button', 'MyThirdComp:button', 0, '', '#selfClosing', '', '', ], ]); }); }); describe('source spans', () => { it('should store the location', () => { expect( humanizeDomSourceSpans( parser.parse('
\na\n
', ' TestComp'), ), ).toEqual([ [ html.Element, 'div', 0, '
\na\n
', '
', '
', ], [html.Attribute, '[prop]', 'v1', ['v1'], '[prop]="v1"'], [html.Attribute, '(e)', 'do()', ['do()'], '(e)="do()"'], [html.Attribute, 'attr', 'v2', ['v2'], 'attr="v2"'], [html.Attribute, 'noValue', '', 'noValue'], [html.Text, '\na\n', 1, ['\na\n'], '\na\n'], ]); }); it('should set the start and end source spans', () => { const node = parser.parse('
a
', 'TestComp').rootNodes[0]; expect(node.startSourceSpan.start.offset).toEqual(0); expect(node.startSourceSpan.end.offset).toEqual(5); expect(node.endSourceSpan!.start.offset).toEqual(6); expect(node.endSourceSpan!.end.offset).toEqual(12); }); // This checks backward compatibility with a previous version of the lexer, which would // treat interpolation expressions as regular HTML escapable text. it('should decode HTML entities in interpolations', () => { expect( humanizeDomSourceSpans( parser.parse( '{{&}}' + '{{▾}}' + '{{▾}}' + '{{&unknown;}}' + '{{& (no semi-colon)}}' + '{{&#xyz; (invalid hex)}}' + '{{BE; (invalid decimal)}}', 'TestComp', ), ), ).toEqual([ [ html.Text, '{{&}}' + '{{\u25BE}}' + '{{\u25BE}}' + '{{&unknown;}}' + '{{& (no semi-colon)}}' + '{{&#xyz; (invalid hex)}}' + '{{BE; (invalid decimal)}}', 0, [''], ['{{', '&', '}}'], [''], ['{{', '▾', '}}'], [''], ['{{', '▾', '}}'], [''], ['{{', '&unknown;', '}}'], [''], ['{{', '& (no semi-colon)', '}}'], [''], ['{{', '&#xyz; (invalid hex)', '}}'], [''], ['{{', 'BE; (invalid decimal)', '}}'], [''], '{{&}}' + '{{▾}}' + '{{▾}}' + '{{&unknown;}}' + '{{& (no semi-colon)}}' + '{{&#xyz; (invalid hex)}}' + '{{BE; (invalid decimal)}}', ], ]); }); it('should decode HTML entities with 5+ hex digits in interpolations', () => { // Test with 🛈 (U+1F6C8 - Circled Information Source) expect( humanizeDomSourceSpans(parser.parse('{{🛈}}' + '{{🛈}}', 'TestComp')), ).toEqual([ [ html.Text, '{{\u{1F6C8}}}' + '{{\u{1F6C8}}}', 0, [''], ['{{', '🛈', '}}'], [''], ['{{', '🛈', '}}'], [''], '{{🛈}}' + '{{🛈}}', ], ]); }); it('should support interpolations in text', () => { expect( humanizeDomSourceSpans(parser.parse('
pre {{ value }} post
', 'TestComp')), ).toEqual([ [html.Element, 'div', 0, '
pre {{ value }} post
', '
', '
'], [ html.Text, ' pre {{ value }} post ', 1, [' pre '], ['{{', ' value ', '}}'], [' post '], ' pre {{ value }} post ', ], ]); }); it('should not set the end source span for void elements', () => { expect(humanizeDomSourceSpans(parser.parse('

', 'TestComp'))).toEqual([ [html.Element, 'div', 0, '

', '
', '
'], [html.Element, 'br', 1, '
', '
', null], ]); }); it('should not set the end source span for multiple void elements', () => { expect(humanizeDomSourceSpans(parser.parse('


', 'TestComp'))).toEqual([ [html.Element, 'div', 0, '


', '
', '
'], [html.Element, 'br', 1, '
', '
', null], [html.Element, 'hr', 1, '
', '
', null], ]); }); it('should not set the end source span for standalone void elements', () => { expect(humanizeDomSourceSpans(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'br', 0, '
', '
', null], ]); }); it('should set the end source span for standalone self-closing elements', () => { expect(humanizeDomSourceSpans(parser.parse('
', 'TestComp'))).toEqual([ [html.Element, 'br', 0, '
', '#selfClosing', '
', '
'], ]); }); it('should set the end source span for self-closing elements', () => { expect(humanizeDomSourceSpans(parser.parse('

', 'TestComp'))).toEqual([ [html.Element, 'div', 0, '

', '
', '
'], [html.Element, 'br', 1, '
', '#selfClosing', '
', '
'], ]); }); it('should not include leading trivia from the following node of an element in the end source', () => { expect( humanizeDomSourceSpans( parser.parse('\n\n\n \n', 'TestComp', { leadingTriviaChars: [' ', '\n', '\r', '\t'], }), ), ).toEqual([ [ html.Element, 'input', 0, '', '#selfClosing', '', '', ], [html.Attribute, 'type', 'text', ['text'], 'type="text"'], [html.Text, '\n\n\n ', 0, ['\n\n\n '], '', '\n\n\n '], [html.Element, 'span', 0, '\n', '', ''], [html.Text, '\n', 1, ['\n'], '', '\n'], ]); }); it('should not set the end source span for elements that are implicitly closed', () => { expect(humanizeDomSourceSpans(parser.parse('

', 'TestComp'))).toEqual([ [html.Element, 'div', 0, '

', '
', '
'], [html.Element, 'p', 1, '

', '

', null], ]); expect(humanizeDomSourceSpans(parser.parse('

  • A
  • B
  • ', 'TestComp'))).toEqual([ [html.Element, 'div', 0, '
  • A
  • B
  • ', '
    ', '
    '], [html.Element, 'li', 1, '
  • ', '
  • ', null], [html.Text, 'A', 2, ['A'], 'A'], [html.Element, 'li', 1, '
  • ', '
  • ', null], [html.Text, 'B', 2, ['B'], 'B'], ]); }); it('should support expansion form', () => { expect( humanizeDomSourceSpans( parser.parse('
    {count, plural, =0 {msg}}
    ', 'TestComp', { tokenizeExpansionForms: true, }), ), ).toEqual([ [html.Element, 'div', 0, '
    {count, plural, =0 {msg}}
    ', '
    ', '
    '], [html.Expansion, 'count', 'plural', 1, '{count, plural, =0 {msg}}'], [html.ExpansionCase, '=0', 2, '=0 {msg}'], ]); }); it('should not report a value span for an attribute without a value', () => { const ast = parser.parse('
    ', 'TestComp'); expect((ast.rootNodes[0] as html.Element).attrs[0].valueSpan).toBeUndefined(); }); it('should report a value span for an attribute with a value', () => { const ast = parser.parse('
    ', 'TestComp'); const attr = (ast.rootNodes[0] as html.Element).attrs[0]; expect(attr.valueSpan!.start.offset).toEqual(10); expect(attr.valueSpan!.end.offset).toEqual(12); }); it('should report a value span for an unquoted attribute value', () => { const ast = parser.parse('
    ', 'TestComp'); const attr = (ast.rootNodes[0] as html.Element).attrs[0]; expect(attr.valueSpan!.start.offset).toEqual(9); expect(attr.valueSpan!.end.offset).toEqual(11); }); }); describe('visitor', () => { it('should visit text nodes', () => { const result = humanizeDom(parser.parse('text', 'TestComp')); expect(result).toEqual([[html.Text, 'text', 0, ['text']]]); }); it('should visit element nodes', () => { const result = humanizeDom(parser.parse('
    ', 'TestComp')); expect(result).toEqual([[html.Element, 'div', 0]]); }); it('should visit attribute nodes', () => { const result = humanizeDom(parser.parse('
    ', 'TestComp')); expect(result).toContain([html.Attribute, 'id', 'foo', ['foo']]); }); it('should visit all nodes', () => { const result = parser.parse( '
    ab
    ', 'TestComp', ); const accumulator: html.Node[] = []; const visitor = new (class implements html.Visitor { visit(node: html.Node, context: any) { accumulator.push(node); } visitElement(element: html.Element, context: any): any { html.visitAll(this, element.attrs); html.visitAll(this, element.directives); html.visitAll(this, element.children); } visitAttribute(attribute: html.Attribute, context: any): any {} visitText(text: html.Text, context: any): any {} visitComment(comment: html.Comment, context: any): any {} visitExpansion(expansion: html.Expansion, context: any): any { html.visitAll(this, expansion.cases); } visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any {} visitBlock(block: html.Block, context: any) { html.visitAll(this, block.parameters); html.visitAll(this, block.children); } visitBlockParameter(parameter: html.BlockParameter, context: any) {} visitLetDeclaration(decl: html.LetDeclaration, context: any) {} visitComponent(component: html.Component, context: any) { html.visitAll(this, component.attrs); html.visitAll(this, component.directives); html.visitAll(this, component.children); } visitDirective(directive: html.Directive, context: any) { html.visitAll(this, directive.attrs); } })(); html.visitAll(visitor, result.rootNodes); expect(accumulator.map((n) => n.constructor)).toEqual([ html.Element, html.Attribute, html.Element, html.Attribute, html.Text, html.Element, html.Text, ]); }); it('should skip typed visit if visit() returns a truthy value', () => { const visitor = new (class implements html.Visitor { visit(node: html.Node, context: any) { return true; } visitElement(element: html.Element, context: any): any { throw Error('Unexpected'); } visitAttribute(attribute: html.Attribute, context: any): any { throw Error('Unexpected'); } visitText(text: html.Text, context: any): any { throw Error('Unexpected'); } visitComment(comment: html.Comment, context: any): any { throw Error('Unexpected'); } visitExpansion(expansion: html.Expansion, context: any): any { throw Error('Unexpected'); } visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { throw Error('Unexpected'); } visitBlock(block: html.Block, context: any) { throw Error('Unexpected'); } visitBlockParameter(parameter: html.BlockParameter, context: any) { throw Error('Unexpected'); } visitLetDeclaration(decl: html.LetDeclaration, context: any) { throw Error('Unexpected'); } visitComponent(component: html.Component, context: any) { throw Error('Unexpected'); } visitDirective(directive: html.Directive, context: any) { throw Error('Unexpected'); } })(); const result = parser.parse('
    ', 'TestComp'); const traversal = html.visitAll(visitor, result.rootNodes); expect(traversal).toEqual([true, true]); }); }); describe('errors', () => { it('should report unexpected closing tags', () => { const errors = parser.parse('

    ', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ [ 'p', 'Unexpected closing tag "p". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:5', ], ]); }); it('gets correct close tag for parent when a child is not closed', () => { const {errors, rootNodes} = parser.parse('
    ', 'TestComp'); expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ [ 'div', 'Unexpected closing tag "div". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:11', ], ]); expect(humanizeNodes(rootNodes, true)).toEqual([ [html.Element, 'div', 0, '
    ', '
    ', '
    '], [html.Element, 'span', 1, '', '', null], ]); }); describe('incomplete element tag', () => { it('should parse and report incomplete tags after the tag name', () => { const {errors, rootNodes} = parser.parse('
    ', 'TestComp'); expect(humanizeNodes(rootNodes, true)).toEqual([ [html.Element, 'div', 0, '
    ', '', ''], [html.Element, 'div', 1, '
    { const {errors, rootNodes} = parser.parse('
    ', 'TestComp'); expect(humanizeNodes(rootNodes, true)).toEqual([ [html.Element, 'div', 0, '
    ', '', ''], ]); expect(humanizeErrors(errors)).toEqual([ ['div', 'Opening tag "div" not terminated.', '0:0'], ]); }); it('should parse and report incomplete tags after quote', () => { const {errors, rootNodes} = parser.parse('
    ', 'TestComp'); expect(humanizeNodes(rootNodes, true)).toEqual([ [html.Element, 'div', 0, '
    ', '', ''], ]); expect(humanizeErrors(errors)).toEqual([ ['div', 'Opening tag "div" not terminated.', '0:0'], ]); }); it('should report subsequent open tags without proper close tag', () => { const errors = parser.parse('', 'TestComp').errors; expect(errors.length).toEqual(2); expect(humanizeErrors(errors)).toEqual([ ['div', 'Opening tag "div" not terminated.', '0:0'], // TODO(ayazhafiz): the following error is unnecessary and can be pruned if we keep // track of the incomplete tag names. [ 'div', 'Unexpected closing tag "div". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:4', ], ]); }); }); it('should report closing tag for void elements', () => { const errors = parser.parse('', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ ['input', 'Void elements do not have end tags "input"', '0:7'], ]); }); it('should report self closing html element', () => { const errors = parser.parse('

    ', 'TestComp').errors; expect(errors.length).toEqual(1); expect(humanizeErrors(errors)).toEqual([ ['p', 'Only void, custom and foreign elements can be self closed "p"', '0:0'], ]); }); it('should not report self closing custom element', () => { expect(parser.parse('', 'TestComp').errors).toEqual([]); }); it('should also report lexer errors', () => { const errors = parser.parse('

    ', 'TestComp').errors; expect(errors.length).toEqual(2); expect(humanizeErrors(errors)).toEqual([ ['Unexpected character "e"', '0:3'], [ 'p', 'Unexpected closing tag "p". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags', '0:14', ], ]); }); }); }); }); export function humanizeErrors(errors: ParseError[]): any[] { return errors.map((e) => { if (e instanceof TreeError) { // Parser errors return [e.elementName, e.msg, humanizeLineColumn(e.span.start)]; } // Tokenizer errors return [e.msg, humanizeLineColumn(e.span.start)]; }); }