/** * @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 {getHtmlTagDefinition} from '../../src/ml_parser/html_tags'; import {tokenize, TokenizeOptions, TokenizeResult} from '../../src/ml_parser/lexer'; import {Token, TokenType} from '../../src/ml_parser/tokens'; import {ParseError, ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util'; describe('HtmlLexer', () => { describe('line/column numbers', () => { it('should work without newlines', () => { expect(tokenizeAndHumanizeLineColumn('a')).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '0:2'], [TokenType.TEXT, '0:3'], [TokenType.TAG_CLOSE, '0:4'], [TokenType.EOF, '0:8'], ]); }); it('should work with one newline', () => { expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '0:2'], [TokenType.TEXT, '0:3'], [TokenType.TAG_CLOSE, '1:1'], [TokenType.EOF, '1:5'], ]); }); it('should work with multiple newlines', () => { expect(tokenizeAndHumanizeLineColumn('\na')).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '1:0'], [TokenType.TEXT, '1:1'], [TokenType.TAG_CLOSE, '2:1'], [TokenType.EOF, '2:5'], ]); }); it('should work with CR and LF', () => { expect(tokenizeAndHumanizeLineColumn('\r\na\r')).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '1:0'], [TokenType.TEXT, '1:1'], [TokenType.TAG_CLOSE, '2:1'], [TokenType.EOF, '2:5'], ]); }); it('should skip over leading trivia for source-span start', () => { expect( tokenizeAndHumanizeFullStart('\n \t a', {leadingTriviaChars: ['\n', ' ', '\t']}), ).toEqual([ [TokenType.TAG_OPEN_START, '0:0', '0:0'], [TokenType.TAG_OPEN_END, '0:2', '0:2'], [TokenType.TEXT, '1:3', '0:3'], [TokenType.TAG_CLOSE, '1:4', '1:4'], [TokenType.EOF, '1:8', '1:8'], ]); }); }); describe('content ranges', () => { it('should only process the text within the range', () => { expect( tokenizeAndHumanizeSourceSpans( 'pre 1\npre 2\npre 3 `line 1\nline 2\nline 3` post 1\n post 2\n post 3', {range: {startPos: 19, startLine: 2, startCol: 7, endPos: 39}}, ), ).toEqual([ [TokenType.TEXT, 'line 1\nline 2\nline 3'], [TokenType.EOF, ''], ]); }); it('should take into account preceding (non-processed) lines and columns', () => { expect( tokenizeAndHumanizeLineColumn( 'pre 1\npre 2\npre 3 `line 1\nline 2\nline 3` post 1\n post 2\n post 3', {range: {startPos: 19, startLine: 2, startCol: 7, endPos: 39}}, ), ).toEqual([ [TokenType.TEXT, '2:7'], [TokenType.EOF, '4:6'], ]); }); }); describe('comments', () => { it('should parse comments', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.COMMENT_START], [TokenType.RAW_TEXT, 't\ne\ns\nt'], [TokenType.COMMENT_END], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.COMMENT_START, ''], [TokenType.EOF, ''], ]); }); it('should report { expect(tokenizeAndHumanizeErrors(' { expect(tokenizeAndHumanizeErrors('')).toEqual([ [TokenType.COMMENT_START, ''], [TokenType.EOF, ''], ]); }); it('should accept comments finishing by too many dashes (odd number)', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.COMMENT_START, ''], [TokenType.EOF, ''], ]); }); }); describe('doctype', () => { it('should parse doctypes', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.DOC_TYPE, 'DOCTYPE html'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.DOC_TYPE, ''], [TokenType.EOF, ''], ]); }); it('should report missing end doctype', () => { expect(tokenizeAndHumanizeErrors(' { it('should parse CDATA', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.CDATA_START], [TokenType.RAW_TEXT, 't\ne\ns\nt'], [TokenType.CDATA_END], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.CDATA_START, ''], [TokenType.EOF, ''], ]); }); it('should report { expect(tokenizeAndHumanizeErrors(' { expect(tokenizeAndHumanizeErrors(' { it('should parse open tags without prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 'test'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse namespace prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, 'ns1', 'test'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse void tags', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 'test'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); }); it('should allow whitespace after the tag name', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 'test'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.EOF, ''], ]); }); describe('tags', () => { it('terminated with EOF', () => { expect(tokenizeAndHumanizeSourceSpans(' { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, ''], [TokenType.INCOMPLETE_TAG_OPEN, ''], [TokenType.EOF, ''], ]); }); it('in attribute', () => { expect(tokenizeAndHumanizeSourceSpans('
')).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, ''], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); it('after quote', () => { expect(tokenizeAndHumanizeSourceSpans('
')).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, ''], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); describe('component tags', () => { const options: TokenizeOptions = {selectorlessEnabled: true}; it('should parse a basic component tag', () => { expect(tokenizeAndHumanizeParts('hello', options)).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', '', ''], [TokenType.COMPONENT_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.COMPONENT_CLOSE, 'MyComp', '', ''], [TokenType.EOF], ]); }); it('should parse a component tag with a tag name', () => { expect(tokenizeAndHumanizeParts('hello', options)).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', '', 'button'], [TokenType.COMPONENT_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.COMPONENT_CLOSE, 'MyComp', '', 'button'], [TokenType.EOF], ]); }); it('should parse a component tag with a tag name and namespace', () => { expect( tokenizeAndHumanizeParts('hello', options), ).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', 'svg', 'title'], [TokenType.COMPONENT_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.COMPONENT_CLOSE, 'MyComp', 'svg', 'title'], [TokenType.EOF], ]); }); it('should parse a self-closing component tag', () => { expect(tokenizeAndHumanizeParts('', options)).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', '', ''], [TokenType.COMPONENT_OPEN_END_VOID], [TokenType.EOF], ]); }); it('should produce spans for component tags', () => { expect( tokenizeAndHumanizeSourceSpans('hello', options), ).toEqual([ [TokenType.COMPONENT_OPEN_START, ''], [TokenType.TEXT, 'hello'], [TokenType.COMPONENT_CLOSE, ''], [TokenType.EOF, ''], ]); }); it('should parse an incomplete component open tag', () => { expect( tokenizeAndHumanizeParts('', options), ).toEqual([ [TokenType.INCOMPLETE_COMPONENT_OPEN, 'MyComp', '', 'span'], [TokenType.ATTR_NAME, '', 'class'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'hi'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'sty'], [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.EOF], ]); }); it('should parse a component tag with raw text', () => { expect( tokenizeAndHumanizeParts(`t\ne\rs\r\nt`, options), ).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', '', 'script'], [TokenType.COMPONENT_OPEN_END], [TokenType.RAW_TEXT, 't\ne\ns\nt'], [TokenType.COMPONENT_CLOSE, 'MyComp', '', 'script'], [TokenType.EOF], ]); }); it('should parse a component tag with escapable raw text', () => { expect( tokenizeAndHumanizeParts(`t\ne\rs\r\nt`, options), ).toEqual([ [TokenType.COMPONENT_OPEN_START, 'MyComp', '', 'title'], [TokenType.COMPONENT_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 't\ne\ns\nt'], [TokenType.COMPONENT_CLOSE, 'MyComp', '', 'title'], [TokenType.EOF], ]); }); }); describe('selectorless directives', () => { const options: TokenizeOptions = {selectorlessEnabled: true}; it('should parse a basic directive', () => { expect(tokenizeAndHumanizeParts('
', options)).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.DIRECTIVE_NAME, 'MyDir'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse a directive with parentheses, but no attributes', () => { expect(tokenizeAndHumanizeParts('
', options)).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.DIRECTIVE_NAME, 'MyDir'], [TokenType.DIRECTIVE_OPEN], [TokenType.DIRECTIVE_CLOSE], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse a directive with a single attribute without a value', () => { expect(tokenizeAndHumanizeParts('
', options)).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.DIRECTIVE_NAME, 'MyDir'], [TokenType.DIRECTIVE_OPEN], [TokenType.ATTR_NAME, '', 'foo'], [TokenType.DIRECTIVE_CLOSE], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse a directive with attributes', () => { const tokens = tokenizeAndHumanizeParts( '
', options, ); expect(tokens).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.DIRECTIVE_NAME, 'MyDir'], [TokenType.DIRECTIVE_OPEN], [TokenType.ATTR_NAME, '', 'static'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'one'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', '[bound]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', '[(twoWay)]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', '#ref'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'name'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', '(click)'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'handler()'], [TokenType.ATTR_QUOTE, '"'], [TokenType.DIRECTIVE_CLOSE], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse a directive mixed in with other attributes', () => { const tokens = tokenizeAndHumanizeParts( '
', options, ); expect(tokens).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.ATTR_NAME, '', 'before'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'value'], [TokenType.ATTR_QUOTE, '"'], [TokenType.DIRECTIVE_NAME, 'OneDir'], [TokenType.DIRECTIVE_OPEN], [TokenType.ATTR_NAME, '', '[one]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '1'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'two'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '2'], [TokenType.ATTR_QUOTE, '"'], [TokenType.DIRECTIVE_CLOSE], [TokenType.ATTR_NAME, '', 'middle'], [TokenType.DIRECTIVE_NAME, 'TwoDir'], [TokenType.DIRECTIVE_NAME, 'ThreeDir'], [TokenType.DIRECTIVE_OPEN], [TokenType.ATTR_NAME, '', '(three)'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'handleThree()'], [TokenType.ATTR_QUOTE, '"'], [TokenType.DIRECTIVE_CLOSE], [TokenType.ATTR_NAME, '', 'after'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'value'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should not pick up selectorless-like text inside a tag', () => { expect(tokenizeAndHumanizeParts('
@MyDir()
', options)).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, '@MyDir()'], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should not pick up selectorless-like text inside an attribute', () => { expect(tokenizeAndHumanizeParts('
', options)).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.ATTR_NAME, '', 'hello'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '@MyDir'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should produce spans for directives', () => { const tokens = tokenizeAndHumanizeSourceSpans( '
', options, ); expect(tokens).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TAG_CLOSE, '
'], [TokenType.EOF, ''], ]); }); it('should not capture whitespace in directive spans', () => { const tokens = tokenizeAndHumanizeSourceSpans( '
', options, ); expect(tokens).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TAG_CLOSE, '
'], [TokenType.EOF, ''], ]); }); }); describe('escapable raw text', () => { it('should parse text', () => { expect(tokenizeAndHumanizeParts(`t\ne\rs\r\nt`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 't\ne\ns\nt'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should detect entities', () => { expect(tokenizeAndHumanizeParts(`&`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, ''], [TokenType.ENCODED_ENTITY, '&', '&'], [TokenType.ESCAPABLE_RAW_TEXT, ''], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should ignore other opening tags', () => { expect(tokenizeAndHumanizeParts(`a<div>`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'a
'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should ignore other closing tags', () => { expect(tokenizeAndHumanizeParts(`a</test>`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans(`a`)).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.ESCAPABLE_RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); describe('parsable data', () => { it('should parse an SVG tag', () => { expect(tokenizeAndHumanizeParts(`<svg:title>test</svg:title>`)).toEqual([ [TokenType.TAG_OPEN_START, 'svg', 'title'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'test'], [TokenType.TAG_CLOSE, 'svg', 'title'], [TokenType.EOF], ]); }); it('should parse an SVG <title> tag with children', () => { expect(tokenizeAndHumanizeParts(`<svg:title><f>test</f></svg:title>`)).toEqual([ [TokenType.TAG_OPEN_START, 'svg', 'title'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'f'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'test'], [TokenType.TAG_CLOSE, '', 'f'], [TokenType.TAG_CLOSE, 'svg', 'title'], [TokenType.EOF], ]); }); }); describe('expansion forms', () => { it('should parse an expansion form', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four} =5 {five} foo {bar} }', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=5'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'five'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, 'foo'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'bar'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse an expansion form with text elements surrounding it', () => { expect( tokenizeAndHumanizeParts('before{one.two, three, =4 {four}}after', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TEXT, 'before'], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, 'after'], [TokenType.EOF], ]); }); it('should parse an expansion form as a tag single child', () => { expect( tokenizeAndHumanizeParts('<div><span>{a, b, =4 {c}}</span></div>', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'a'], [TokenType.RAW_TEXT, 'b'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'c'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse an expansion form with whitespace surrounding it', () => { expect( tokenizeAndHumanizeParts('<div><span> {a, b, =4 {c}} </span></div>', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, ' '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'a'], [TokenType.RAW_TEXT, 'b'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'c'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, ' '], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse an expansion forms with elements in it', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four <b>a</b>}}', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four '], [TokenType.TAG_OPEN_START, '', 'b'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'a'], [TokenType.TAG_CLOSE, '', 'b'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse an expansion forms containing an interpolation', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four {{a}}}}', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four '], [TokenType.INTERPOLATION, '{{', 'a', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse nested expansion forms', () => { expect( tokenizeAndHumanizeParts(`{one.two, three, =4 { {xx, yy, =x {one}} }}`, { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'xx'], [TokenType.RAW_TEXT, 'yy'], [TokenType.EXPANSION_CASE_VALUE, '=x'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'one'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, ' '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); describe('[line ending normalization', () => { describe('{escapedString: true}', () => { it('should normalize line-endings in expansion forms if `i18nNormalizeLineEndingsInICUs` is true', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, { tokenizeExpansionForms: true, escapedString: true, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions).toEqual([]); }); it('should not normalize line-endings in ICU expressions when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(1); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); }); it('should not normalize line endings in nested expansion forms when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\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` + `}`, {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'zero \n '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n p.gender'], [TokenType.RAW_TEXT, 'select'], [TokenType.EXPANSION_CASE_VALUE, 'male'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'm'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(2); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); expect(result.nonNormalizedIcuExpressions![1].sourceSpan.toString()).toEqual( '\r\n p.gender', ); }); }); describe('{escapedString: false}', () => { it('should normalize line-endings in expansion forms if `i18nNormalizeLineEndingsInICUs` is true', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, { tokenizeExpansionForms: true, escapedString: false, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions).toEqual([]); }); it('should not normalize line-endings in ICU expressions when `i18nNormalizeLineEndingsInICUs` is not defined (escapedString:false)', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, {tokenizeExpansionForms: true, escapedString: false}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(1); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); }); it('should not normalize line endings in nested expansion forms when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\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` + `}`, {tokenizeExpansionForms: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'zero \n '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n p.gender'], [TokenType.RAW_TEXT, 'select'], [TokenType.EXPANSION_CASE_VALUE, 'male'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'm'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(2); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); expect(result.nonNormalizedIcuExpressions![1].sourceSpan.toString()).toEqual( '\r\n p.gender', ); }); }); }); }); describe('errors', () => { it('should report unescaped "{" on error', () => { expect( tokenizeAndHumanizeErrors(`<p>before { after</p>`, {tokenizeExpansionForms: true}), ).toEqual([ [ `Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`, '0:21', ], ]); }); it('should report unescaped "{" as an error, even after a prematurely terminated interpolation', () => { expect( tokenizeAndHumanizeErrors(`<code>{{b}<!---->}</code><pre>import {a} from 'a';</pre>`, { tokenizeExpansionForms: true, }), ).toEqual([ [ `Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`, '0:56', ], ]); }); it('should include 2 lines of context in message', () => { const src = '111\n222\n333\nE\n444\n555\n666\n'; const file = new ParseSourceFile(src, 'file://'); const location = new ParseLocation(file, 12, 123, 456); const span = new ParseSourceSpan(location, location); const error = new ParseError(span, '**ERROR**'); expect(error.toString()).toEqual( `**ERROR** ("\n222\n333\n[ERROR ->]E\n444\n555\n"): file://@123:456`, ); }); }); describe('unicode characters', () => { it('should support unicode characters', () => { expect(tokenizeAndHumanizeSourceSpans(`<p>İ</p>`)).toEqual([ [TokenType.TAG_OPEN_START, '<p'], [TokenType.TAG_OPEN_END, '>'], [TokenType.TEXT, 'İ'], [TokenType.TAG_CLOSE, '</p>'], [TokenType.EOF, ''], ]); }); }); describe('(processing escaped strings)', () => { it('should unescape standard escape sequences', () => { expect(tokenizeAndHumanizeParts("\\' \\' \\'", {escapedString: true})).toEqual([ [TokenType.TEXT, "' ' '"], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\" \\" \\"', {escapedString: true})).toEqual([ [TokenType.TEXT, '" " "'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\` \\` \\`', {escapedString: true})).toEqual([ [TokenType.TEXT, '` ` `'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\\\ \\\\ \\\\', {escapedString: true})).toEqual([ [TokenType.TEXT, '\\ \\ \\'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\n \\n \\n', {escapedString: true})).toEqual([ [TokenType.TEXT, '\n \n \n'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\r{{\\r}}\\r', {escapedString: true})).toEqual([ // post processing converts `\r` to `\n` [TokenType.TEXT, '\n'], [TokenType.INTERPOLATION, '{{', '\n', '}}'], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\v \\v \\v', {escapedString: true})).toEqual([ [TokenType.TEXT, '\v \v \v'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\t \\t \\t', {escapedString: true})).toEqual([ [TokenType.TEXT, '\t \t \t'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\b \\b \\b', {escapedString: true})).toEqual([ [TokenType.TEXT, '\b \b \b'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\f \\f \\f', {escapedString: true})).toEqual([ [TokenType.TEXT, '\f \f \f'], [TokenType.EOF], ]); expect( tokenizeAndHumanizeParts('\\\' \\" \\` \\\\ \\n \\r \\v \\t \\b \\f', { escapedString: true, }), ).toEqual([[TokenType.TEXT, '\' " ` \\ \n \n \v \t \b \f'], [TokenType.EOF]]); }); it('should unescape null sequences', () => { expect(tokenizeAndHumanizeParts('\\0', {escapedString: true})).toEqual([[TokenType.EOF]]); // \09 is not an octal number so the \0 is taken as EOF expect(tokenizeAndHumanizeParts('\\09', {escapedString: true})).toEqual([[TokenType.EOF]]); }); it('should unescape octal sequences', () => { // \19 is read as an octal `\1` followed by a normal char `9` // \1234 is read as an octal `\123` followed by a normal char `4` // \999 is not an octal number so its backslash just gets removed. expect( tokenizeAndHumanizeParts('\\001 \\01 \\1 \\12 \\223 \\19 \\2234 \\999', { escapedString: true, }), ).toEqual([[TokenType.TEXT, '\x01 \x01 \x01 \x0A \x93 \x019 \x934 999'], [TokenType.EOF]]); }); it('should unescape hex sequences', () => { expect(tokenizeAndHumanizeParts('\\x12 \\x4F \\xDC', {escapedString: true})).toEqual([ [TokenType.TEXT, '\x12 \x4F \xDC'], [TokenType.EOF], ]); }); it('should report an error on an invalid hex sequence', () => { expect(tokenizeAndHumanizeErrors('\\xGG', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:2'], ]); expect(tokenizeAndHumanizeErrors('abc \\x xyz', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:6'], ]); expect(tokenizeAndHumanizeErrors('abc\\x', {escapedString: true})).toEqual([ ['Unexpected character "EOF"', '0:5'], ]); }); it('should unescape fixed length Unicode sequences', () => { expect(tokenizeAndHumanizeParts('\\u0123 \\uABCD', {escapedString: true})).toEqual([ [TokenType.TEXT, '\u0123 \uABCD'], [TokenType.EOF], ]); }); it('should error on an invalid fixed length Unicode sequence', () => { expect(tokenizeAndHumanizeErrors('\\uGGGG', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:2'], ]); }); it('should unescape variable length Unicode sequences', () => { expect( tokenizeAndHumanizeParts('\\u{01} \\u{ABC} \\u{1234} \\u{123AB}', {escapedString: true}), ).toEqual([[TokenType.TEXT, '\u{01} \u{ABC} \u{1234} \u{123AB}'], [TokenType.EOF]]); }); it('should error on an invalid variable length Unicode sequence', () => { expect(tokenizeAndHumanizeErrors('\\u{GG}', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:3'], ]); }); it('should unescape line continuations', () => { expect(tokenizeAndHumanizeParts('abc\\\ndef', {escapedString: true})).toEqual([ [TokenType.TEXT, 'abcdef'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\\nx\\\ny\\\n', {escapedString: true})).toEqual([ [TokenType.TEXT, 'xy'], [TokenType.EOF], ]); }); it('should remove backslash from "non-escape" sequences', () => { expect(tokenizeAndHumanizeParts('a g ~', {escapedString: true})).toEqual([ [TokenType.TEXT, 'a g ~'], [TokenType.EOF], ]); }); it('should unescape sequences in plain text', () => { expect( tokenizeAndHumanizeParts('abc\ndef\\nghi\\tjkl\\`\\\'\\"mno', {escapedString: true}), ).toEqual([[TokenType.TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.EOF]]); }); it('should unescape sequences in raw text', () => { expect( tokenizeAndHumanizeParts('<script>abc\ndef\\nghi\\tjkl\\`\\\'\\"mno</script>', { escapedString: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should unescape sequences in escapable raw text', () => { expect( tokenizeAndHumanizeParts('<title>abc\ndef\\nghi\\tjkl\\`\\\'\\"mno', { escapedString: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should parse over escape sequences in tag definitions', () => { expect( tokenizeAndHumanizeParts('', {escapedString: true}), ).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'c'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, 'd'], [TokenType.ATTR_QUOTE, "'"], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse over escaped new line in tag definitions', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should parse over escaped characters in tag definitions', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should unescape characters in tag names', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 'td'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'td'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); it('should unescape characters in attributes', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'd'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'e'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should parse over escaped new line in attribute values', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should tokenize the correct span when there are escape sequences', () => { const text = 'selector: "app-root",\ntemplate: "line 1\\n\\"line 2\\"\\nline 3",\ninputs: []'; const range = { startPos: 33, startLine: 1, startCol: 10, endPos: 59, }; expect(tokenizeAndHumanizeParts(text, {range, escapedString: true})).toEqual([ [TokenType.TEXT, 'line 1\n"line 2"\nline 3'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans(text, {range, escapedString: true})).toEqual([ [TokenType.TEXT, 'line 1\\n\\"line 2\\"\\nline 3'], [TokenType.EOF, ''], ]); }); it('should account for escape sequences when computing source spans ', () => { const text = 'line 1\n' + // <- unescaped line break 'line 2\\n' + // <- escaped line break 'line 3\\\n' + // <- line continuation ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 1'], [TokenType.TAG_CLOSE, '', 't'], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 2'], [TokenType.TAG_CLOSE, '', 't'], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 3'], // <- line continuation does not appear in token [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeLineColumn(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '0:2'], [TokenType.TEXT, '0:3'], [TokenType.TAG_CLOSE, '0:9'], [TokenType.TEXT, '0:13'], // <- real newline increments the row [TokenType.TAG_OPEN_START, '1:0'], [TokenType.TAG_OPEN_END, '1:2'], [TokenType.TEXT, '1:3'], [TokenType.TAG_CLOSE, '1:9'], [TokenType.TEXT, '1:13'], // <- escaped newline does not increment the row [TokenType.TAG_OPEN_START, '1:15'], [TokenType.TAG_OPEN_END, '1:17'], [TokenType.TEXT, '1:18'], // <- the line continuation increments the row [TokenType.TAG_CLOSE, '2:0'], [TokenType.EOF, '2:4'], ]); expect(tokenizeAndHumanizeSourceSpans(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 1'], [TokenType.TAG_CLOSE, ''], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 2'], [TokenType.TAG_CLOSE, ''], [TokenType.TEXT, '\\n'], [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 3\\\n'], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); }); describe('@let declarations', () => { it('should parse a @let declaration', () => { expect(tokenizeAndHumanizeParts('@let foo = 123 + 456;')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '123 + 456'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse @let declarations with arbitrary number of spaces', () => { const expected = [ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '123 + 456'], [TokenType.LET_END], [TokenType.EOF], ]; expect( tokenizeAndHumanizeParts('@let foo = 123 + 456;'), ).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo=123 + 456;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo =123 + 456;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo= 123 + 456;')).toEqual(expected); }); it('should parse a @let declaration with newlines before/after its name', () => { const expected = [ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '123'], [TokenType.LET_END], [TokenType.EOF], ]; expect(tokenizeAndHumanizeParts('@let\nfoo = 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let \nfoo = 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let \n foo = 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo\n= 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo\n = 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let foo \n = 123;')).toEqual(expected); expect(tokenizeAndHumanizeParts('@let \n foo \n = 123;')).toEqual(expected); }); it('should parse a @let declaration with new lines in its value', () => { expect(tokenizeAndHumanizeParts('@let foo = \n123 + \n 456 + \n789\n;')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '123 + \n 456 + \n789\n'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse a @let declaration inside of a block', () => { expect(tokenizeAndHumanizeParts('@defer {@let foo = 123 + 456;}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'defer'], [TokenType.BLOCK_OPEN_END], [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '123 + 456'], [TokenType.LET_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse @let declaration using semicolon inside of a string', () => { expect(tokenizeAndHumanizeParts(`@let foo = 'a; b';`)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, `'a; b'`], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(`@let foo = "';'";`)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, `"';'"`], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse @let declaration using escaped quotes in a string', () => { const markup = `@let foo = '\\';\\'' + "\\",";`; expect(tokenizeAndHumanizeParts(markup)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, `'\\';\\'' + "\\","`], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse @let declaration using function calls in its value', () => { const markup = '@let foo = fn(a, b) + fn2(c, d, e);'; expect(tokenizeAndHumanizeParts(markup)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, 'fn(a, b) + fn2(c, d, e)'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse @let declarations using array literals in their value', () => { expect(tokenizeAndHumanizeParts('@let foo = [1, 2, 3];')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '[1, 2, 3]'], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo = [0, [foo[1]], 3];')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '[0, [foo[1]], 3]'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse @let declarations using object literals', () => { expect(tokenizeAndHumanizeParts('@let foo = {a: 1, b: {c: something + 2}};')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '{a: 1, b: {c: something + 2}}'], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo = {};')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '{}'], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo = {foo: ";"};')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '{foo: ";"}'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should parse a @let declaration containing complex expression', () => { const markup = '@let foo = fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [\',\']})]});'; expect(tokenizeAndHumanizeParts(markup)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, 'fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [\',\']})]})'], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should handle @let declaration with invalid syntax in the value', () => { expect(tokenizeAndHumanizeErrors(`@let foo = ";`)).toEqual([ ['Unexpected character "EOF"', '0:13'], ]); expect(tokenizeAndHumanizeParts(`@let foo = {a: 1,;`)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '{a: 1,'], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(`@let foo = [1, ;`)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, '[1, '], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(`@let foo = fn(;`)).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, 'fn('], [TokenType.LET_END], [TokenType.EOF], ]); }); // This case is a bit odd since an `@let` without a value is invalid, // but it will be validated further down in the parsing pipeline. it('should parse a @let declaration without a value', () => { expect(tokenizeAndHumanizeParts('@let foo =;')).toEqual([ [TokenType.LET_START, 'foo'], [TokenType.LET_VALUE, ''], [TokenType.LET_END], [TokenType.EOF], ]); }); it('should handle no space after @let', () => { expect(tokenizeAndHumanizeParts('@letFoo = 123;')).toEqual([ [TokenType.INCOMPLETE_LET, '@let'], [TokenType.TEXT, 'Foo = 123;'], [TokenType.EOF], ]); }); it('should handle unsupported characters in the name of @let', () => { expect(tokenizeAndHumanizeParts('@let foo\\bar = 123;')).toEqual([ [TokenType.INCOMPLETE_LET, 'foo'], [TokenType.TEXT, '\\bar = 123;'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let #foo = 123;')).toEqual([ [TokenType.INCOMPLETE_LET, ''], [TokenType.TEXT, '#foo = 123;'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo\nbar = 123;')).toEqual([ [TokenType.INCOMPLETE_LET, 'foo'], [TokenType.TEXT, 'bar = 123;'], [TokenType.EOF], ]); }); it('should handle digits in the name of an @let', () => { expect(tokenizeAndHumanizeParts('@let a123 = foo;')).toEqual([ [TokenType.LET_START, 'a123'], [TokenType.LET_VALUE, 'foo'], [TokenType.LET_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let 123a = 123;')).toEqual([ [TokenType.INCOMPLETE_LET, ''], [TokenType.TEXT, '123a = 123;'], [TokenType.EOF], ]); }); it('should handle an @let declaration without an ending token', () => { expect(tokenizeAndHumanizeParts('@let foo = 123 + 456')).toEqual([ [TokenType.INCOMPLETE_LET, 'foo'], [TokenType.LET_VALUE, '123 + 456'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo = 123 + 456 ')).toEqual([ [TokenType.INCOMPLETE_LET, 'foo'], [TokenType.LET_VALUE, '123 + 456 '], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@let foo = 123, bar = 456')).toEqual([ [TokenType.INCOMPLETE_LET, 'foo'], [TokenType.LET_VALUE, '123, bar = 456'], [TokenType.EOF], ]); }); it('should not parse @let inside an interpolation', () => { expect(tokenizeAndHumanizeParts('{{ @let foo = 123; }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' @let foo = 123; ', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); }); describe('attributes', () => { it('should parse attributes without prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with interpolation', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', 'v', '}}'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 's'], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', 'm', '}}'], [TokenType.ATTR_VALUE_TEXT, 'e'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'c'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 's'], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', 'm//c', '}}'], [TokenType.ATTR_VALUE_TEXT, 'e'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should end interpolation on an unescaped matching quote', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', ' a \\" \' b '], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts("")).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', ' a " \\\' b '], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_QUOTE, "'"], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, 'ns1', 'a'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes whose prefix is not valid', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', '(ns1:a)'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with single quote value', () => { expect(tokenizeAndHumanizeParts("")).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, "'"], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with double quote value', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with unquoted value', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with unquoted interpolation value', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 'a'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_VALUE_INTERPOLATION, '{{', 'link.text', '}}'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse bound inputs with expressions containing newlines', () => { expect( tokenizeAndHumanizeParts(``), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'app-component'], [TokenType.ATTR_NAME, '', '[attr]'], [TokenType.ATTR_QUOTE, '"'], [ TokenType.ATTR_VALUE_TEXT, '[\n' + " {text: 'some text',url:'//www.google.com'},\n" + " {text:'other text',url:'//www.google.com'}]", ], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with empty quoted value', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should allow whitespace', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with entities in values', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ENCODED_ENTITY, 'A', 'A'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ENCODED_ENTITY, 'A', 'A'], [TokenType.ATTR_VALUE_TEXT, ''], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should not decode entities without trailing ";"', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '&'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'c&&d'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse attributes with "&" in values', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'b && c &'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse values with CR and LF', () => { expect(tokenizeAndHumanizeParts("")).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, 't\ne\ns\nt'], [TokenType.ATTR_QUOTE, "'"], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.EOF, ''], ]); }); it('should report missing closing single quote', () => { expect(tokenizeAndHumanizeErrors(" { expect(tokenizeAndHumanizeErrors(' { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.text-primary/80]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect( tokenizeAndHumanizeParts(''), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.data-active:text-green-300/80]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', "[class.data-[size='large']:p-8]"], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', "[class.data-[size='large']:p-8]"], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect( tokenizeAndHumanizeParts(``), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', "[class.data-[size='hello white space']]"], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect( tokenizeAndHumanizeParts( ``, ), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.text-primary/80]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', '[class.data-active:text-green-300/80]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr2'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', `[class.data-[size='large']:p-8]`], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr3'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', `some-attr`], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); }); it('should allow mismatched square brackets in attribute name', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.a]b]c]'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'expr'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.a[]][[]]b]][c]'], [TokenType.TAG_OPEN_END_VOID], [TokenType.EOF], ]); }); it('should stop permissive parsing of square brackets on new line', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, '', 'foo'], [TokenType.ATTR_NAME, '', '[class.text-'], [TokenType.ATTR_NAME, '', 'primary'], [TokenType.TEXT, '80]="expr"/>'], [TokenType.EOF], ]); }); }); describe('closing tags', () => { it('should parse closing tags without prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_CLOSE, '', 'test'], [TokenType.EOF], ]); }); it('should parse closing tags with prefix', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_CLOSE, 'ns1', 'test'], [TokenType.EOF], ]); }); it('should allow whitespace', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.TAG_CLOSE, '', 'test'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('')).toEqual([ [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); it('should report missing name after { expect(tokenizeAndHumanizeErrors('', () => { expect(tokenizeAndHumanizeErrors(' { it('should parse named entities', () => { expect(tokenizeAndHumanizeParts('a&b')).toEqual([ [TokenType.TEXT, 'a'], [TokenType.ENCODED_ENTITY, '&', '&'], [TokenType.TEXT, 'b'], [TokenType.EOF], ]); }); it('should parse named entities containing digits', () => { expect(tokenizeAndHumanizeParts('¹')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, '\u00B9', '¹'], [TokenType.TEXT, ''], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('½')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, '\u00BD', '½'], [TokenType.TEXT, ''], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('▓')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, '\u2593', '▓'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should parse hexadecimal entities', () => { expect(tokenizeAndHumanizeParts('AA')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, 'A', 'A'], [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, 'A', 'A'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should parse decimal entities', () => { expect(tokenizeAndHumanizeParts('A')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, 'A', 'A'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should parse entities with more than 4 hex digits', () => { // Test 5 hex digit entity: 🛈 (🛈 - Circled Information Source) expect(tokenizeAndHumanizeParts('🛈')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, '\u{1F6C8}', '🛈'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should parse entities with more than 4 decimal digits', () => { // Test decimal entity: 🛈 (🛈 - Circled Information Source) expect(tokenizeAndHumanizeParts('🛈')).toEqual([ [TokenType.TEXT, ''], [TokenType.ENCODED_ENTITY, '\u{1F6C8}', '🛈'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('a&b')).toEqual([ [TokenType.TEXT, 'a'], [TokenType.ENCODED_ENTITY, '&'], [TokenType.TEXT, 'b'], [TokenType.EOF, ''], ]); }); it('should report malformed/unknown entities', () => { expect(tokenizeAndHumanizeErrors('&tbo;')).toEqual([ ['Unknown entity "tbo" - use the "&#;" or "&#x;" syntax', '0:0'], ]); expect(tokenizeAndHumanizeErrors('sdf;')).toEqual([ [ 'Unable to parse entity "s" - decimal character reference entities must end with ";"', '0:4', ], ]); expect(tokenizeAndHumanizeErrors(' sdf;')).toEqual([ [ 'Unable to parse entity " s" - hexadecimal character reference entities must end with ";"', '0:5', ], ]); expect(tokenizeAndHumanizeErrors('઼')).toEqual([['Unexpected character "EOF"', '0:6']]); }); it('should not parse js object methods', () => { expect(tokenizeAndHumanizeErrors('&valueOf;')).toEqual([ ['Unknown entity "valueOf" - use the "&#;" or "&#x;" syntax', '0:0'], ]); }); }); describe('regular text', () => { it('should parse text', () => { expect(tokenizeAndHumanizeParts('a')).toEqual([[TokenType.TEXT, 'a'], [TokenType.EOF]]); }); it('should parse interpolation', () => { expect( tokenizeAndHumanizeParts('{{ a }}b{{ c // comment }}d{{ e "}} \' " f }}g{{ h // " i }}'), ).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a ', '}}'], [TokenType.TEXT, 'b'], [TokenType.INTERPOLATION, '{{', ' c // comment ', '}}'], [TokenType.TEXT, 'd'], [TokenType.INTERPOLATION, '{{', ' e "}} \' " f ', '}}'], [TokenType.TEXT, 'g'], [TokenType.INTERPOLATION, '{{', ' h // " i ', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans('{{ a }}b{{ c // comment }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{ a }}'], [TokenType.TEXT, 'b'], [TokenType.INTERPOLATION, '{{ c // comment }}'], [TokenType.TEXT, ''], [TokenType.EOF, ''], ]); }); it('should handle CR & LF in text', () => { expect(tokenizeAndHumanizeParts('t\ne\rs\r\nt')).toEqual([ [TokenType.TEXT, 't\ne\ns\nt'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans('t\ne\rs\r\nt')).toEqual([ [TokenType.TEXT, 't\ne\rs\r\nt'], [TokenType.EOF, ''], ]); }); it('should handle CR & LF in interpolation', () => { expect(tokenizeAndHumanizeParts('{{t\ne\rs\r\nt}}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', 't\ne\ns\nt', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans('{{t\ne\rs\r\nt}}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{t\ne\rs\r\nt}}'], [TokenType.TEXT, ''], [TokenType.EOF, ''], ]); }); it('should parse entities', () => { expect(tokenizeAndHumanizeParts('a&b')).toEqual([ [TokenType.TEXT, 'a'], [TokenType.ENCODED_ENTITY, '&', '&'], [TokenType.TEXT, 'b'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans('a&b')).toEqual([ [TokenType.TEXT, 'a'], [TokenType.ENCODED_ENTITY, '&'], [TokenType.TEXT, 'b'], [TokenType.EOF, ''], ]); }); it('should parse text starting with "&"', () => { expect(tokenizeAndHumanizeParts('a && b &')).toEqual([ [TokenType.TEXT, 'a && b &'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans('a')).toEqual([ [TokenType.TEXT, 'a'], [TokenType.EOF, ''], ]); }); it('should allow "<" in text nodes', () => { expect(tokenizeAndHumanizeParts('{{ a < b ? c : d }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a < b ? c : d ', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans('

a')).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'a'], [TokenType.INCOMPLETE_TAG_OPEN, ''], [TokenType.EOF, ''], ]); expect(tokenizeAndHumanizeParts('< a>')).toEqual([[TokenType.TEXT, '< a>'], [TokenType.EOF]]); }); it('should break out of interpolation in text token on valid start tag', () => { expect(tokenizeAndHumanizeParts('{{ a d }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a '], [TokenType.TEXT, ''], [TokenType.TAG_OPEN_START, '', 'b'], [TokenType.ATTR_NAME, '', '&&'], [TokenType.ATTR_NAME, '', 'c'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, ' d '], [TokenType.BLOCK_CLOSE], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should break out of interpolation in text token on valid comment', () => { expect(tokenizeAndHumanizeParts('{{ a }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a }'], [TokenType.TEXT, ''], [TokenType.COMMENT_START], [TokenType.RAW_TEXT, ''], [TokenType.COMMENT_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should end interpolation on a valid closing tag', () => { expect(tokenizeAndHumanizeParts('

{{ a

')).toEqual([ [TokenType.TAG_OPEN_START, '', 'p'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a '], [TokenType.TEXT, ''], [TokenType.TAG_CLOSE, '', 'p'], [TokenType.EOF], ]); }); it('should break out of interpolation in text token on valid CDATA', () => { expect(tokenizeAndHumanizeParts('{{ a }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' a }'], [TokenType.TEXT, ''], [TokenType.CDATA_START], [TokenType.RAW_TEXT, ''], [TokenType.CDATA_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should ignore invalid start tag in interpolation', () => { // Note that if the `<=` is considered an "end of text" then the following `{` would // incorrectly be considered part of an ICU. expect( tokenizeAndHumanizeParts(`{{'<={'}}`, {tokenizeExpansionForms: true}), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'code'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', "'<={'", '}}'], [TokenType.TEXT, ''], [TokenType.TAG_CLOSE, '', 'code'], [TokenType.EOF], ]); }); it('should parse start tags quotes in place of an attribute name as text', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, '', 't'], [TokenType.TEXT, '">'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts("")).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, '', 't'], [TokenType.TEXT, "'>"], [TokenType.EOF], ]); }); it('should parse start tags quotes in place of an attribute name (after a valid attribute)', () => { expect(tokenizeAndHumanizeParts('')).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, '"'], // TODO(ayazhafiz): the " symbol should be a synthetic attribute, // allowing us to complete the opening tag correctly. [TokenType.TEXT, '">'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts("")).toEqual([ [TokenType.INCOMPLETE_TAG_OPEN, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, "'"], // TODO(ayazhafiz): the ' symbol should be a synthetic attribute, // allowing us to complete the opening tag correctly. [TokenType.TEXT, "'>"], [TokenType.EOF], ]); }); it('should be able to escape {', () => { expect(tokenizeAndHumanizeParts('{{ "{" }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' "{" ', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should be able to escape {{', () => { expect(tokenizeAndHumanizeParts('{{ "{{" }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' "{{" ', '}}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should capture everything up to the end of file in the interpolation expression part if there are mismatched quotes', () => { expect(tokenizeAndHumanizeParts('{{ "{{a}}\' }}')).toEqual([ [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', ' "{{a}}\' }}'], [TokenType.TEXT, ''], [TokenType.EOF], ]); }); it('should treat expansion form as text when they are not parsed', () => { expect( tokenizeAndHumanizeParts('{a, b, =4 {c}}', { tokenizeExpansionForms: false, tokenizeBlocks: false, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, '{a, b, =4 {c}}'], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.EOF], ]); }); }); describe('raw text', () => { it('should parse text', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, 't\ne\ns\nt'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should not detect entities', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, '&'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should ignore other opening tags', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, 'a
'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should ignore other closing tags', () => { expect(tokenizeAndHumanizeParts(``)).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans(``)).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); describe('escapable raw text', () => { it('should parse text', () => { expect(tokenizeAndHumanizeParts(`t\ne\rs\r\nt`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 't\ne\ns\nt'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should detect entities', () => { expect(tokenizeAndHumanizeParts(`&`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, ''], [TokenType.ENCODED_ENTITY, '&', '&'], [TokenType.ESCAPABLE_RAW_TEXT, ''], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should ignore other opening tags', () => { expect(tokenizeAndHumanizeParts(`a<div>`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'a
'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should ignore other closing tags', () => { expect(tokenizeAndHumanizeParts(`a</test>`)).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should store the locations', () => { expect(tokenizeAndHumanizeSourceSpans(`a`)).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.ESCAPABLE_RAW_TEXT, 'a'], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); describe('parsable data', () => { it('should parse an SVG tag', () => { expect(tokenizeAndHumanizeParts(`<svg:title>test</svg:title>`)).toEqual([ [TokenType.TAG_OPEN_START, 'svg', 'title'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'test'], [TokenType.TAG_CLOSE, 'svg', 'title'], [TokenType.EOF], ]); }); it('should parse an SVG <title> tag with children', () => { expect(tokenizeAndHumanizeParts(`<svg:title><f>test</f></svg:title>`)).toEqual([ [TokenType.TAG_OPEN_START, 'svg', 'title'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'f'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'test'], [TokenType.TAG_CLOSE, '', 'f'], [TokenType.TAG_CLOSE, 'svg', 'title'], [TokenType.EOF], ]); }); }); describe('expansion forms', () => { it('should parse an expansion form', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four} =5 {five} foo {bar} }', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=5'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'five'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, 'foo'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'bar'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse an expansion form with text elements surrounding it', () => { expect( tokenizeAndHumanizeParts('before{one.two, three, =4 {four}}after', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TEXT, 'before'], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, 'after'], [TokenType.EOF], ]); }); it('should parse an expansion form as a tag single child', () => { expect( tokenizeAndHumanizeParts('<div><span>{a, b, =4 {c}}</span></div>', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'a'], [TokenType.RAW_TEXT, 'b'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'c'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse an expansion form with whitespace surrounding it', () => { expect( tokenizeAndHumanizeParts('<div><span> {a, b, =4 {c}} </span></div>', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.TAG_OPEN_END], [TokenType.TAG_OPEN_START, '', 'span'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, ' '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'a'], [TokenType.RAW_TEXT, 'b'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'c'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, ' '], [TokenType.TAG_CLOSE, '', 'span'], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse an expansion forms with elements in it', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four <b>a</b>}}', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four '], [TokenType.TAG_OPEN_START, '', 'b'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'a'], [TokenType.TAG_CLOSE, '', 'b'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse an expansion forms containing an interpolation', () => { expect( tokenizeAndHumanizeParts('{one.two, three, =4 {four {{a}}}}', { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four '], [TokenType.INTERPOLATION, '{{', 'a', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); it('should parse nested expansion forms', () => { expect( tokenizeAndHumanizeParts(`{one.two, three, =4 { {xx, yy, =x {one}} }}`, { tokenizeExpansionForms: true, }), ).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'xx'], [TokenType.RAW_TEXT, 'yy'], [TokenType.EXPANSION_CASE_VALUE, '=x'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'one'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, ' '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); }); describe('[line ending normalization', () => { describe('{escapedString: true}', () => { it('should normalize line-endings in expansion forms if `i18nNormalizeLineEndingsInICUs` is true', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, { tokenizeExpansionForms: true, escapedString: true, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions).toEqual([]); }); it('should not normalize line-endings in ICU expressions when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(1); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); }); it('should not normalize line endings in nested expansion forms when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\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` + `}`, {tokenizeExpansionForms: true, escapedString: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'zero \n '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n p.gender'], [TokenType.RAW_TEXT, 'select'], [TokenType.EXPANSION_CASE_VALUE, 'male'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'm'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(2); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); expect(result.nonNormalizedIcuExpressions![1].sourceSpan.toString()).toEqual( '\r\n p.gender', ); }); }); describe('{escapedString: false}', () => { it('should normalize line-endings in expansion forms if `i18nNormalizeLineEndingsInICUs` is true', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, { tokenizeExpansionForms: true, escapedString: false, i18nNormalizeLineEndingsInICUs: true, }, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions).toEqual([]); }); it('should not normalize line-endings in ICU expressions when `i18nNormalizeLineEndingsInICUs` is not defined (escapeString: false)', () => { const result = tokenizeWithoutErrors( `{\r\n` + ` messages.length,\r\n` + ` plural,\r\n` + ` =0 {You have \r\nno\r\n messages}\r\n` + ` =1 {One {{message}}}}\r\n`, {tokenizeExpansionForms: true, escapedString: false}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'You have \nno\n messages'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=1'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'One '], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(1); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); }); it('should not normalize line endings in nested expansion forms when `i18nNormalizeLineEndingsInICUs` is not defined', () => { const result = tokenizeWithoutErrors( `{\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` + `}`, {tokenizeExpansionForms: true}, ); expect(humanizeParts(result.tokens)).toEqual([ [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n messages.length'], [TokenType.RAW_TEXT, 'plural'], [TokenType.EXPANSION_CASE_VALUE, '=0'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'zero \n '], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, '\r\n p.gender'], [TokenType.RAW_TEXT, 'select'], [TokenType.EXPANSION_CASE_VALUE, 'male'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'm'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.TEXT, '\n '], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.EOF], ]); expect(result.nonNormalizedIcuExpressions!.length).toBe(2); expect(result.nonNormalizedIcuExpressions![0].sourceSpan.toString()).toEqual( '\r\n messages.length', ); expect(result.nonNormalizedIcuExpressions![1].sourceSpan.toString()).toEqual( '\r\n p.gender', ); }); }); }); }); describe('errors', () => { it('should report unescaped "{" on error', () => { expect( tokenizeAndHumanizeErrors(`<p>before { after</p>`, {tokenizeExpansionForms: true}), ).toEqual([ [ `Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`, '0:21', ], ]); }); it('should report unescaped "{" as an error, even after a prematurely terminated interpolation', () => { expect( tokenizeAndHumanizeErrors(`<code>{{b}<!---->}</code><pre>import {a} from 'a';</pre>`, { tokenizeExpansionForms: true, }), ).toEqual([ [ `Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)`, '0:56', ], ]); }); it('should include 2 lines of context in message', () => { const src = '111\n222\n333\nE\n444\n555\n666\n'; const file = new ParseSourceFile(src, 'file://'); const location = new ParseLocation(file, 12, 123, 456); const span = new ParseSourceSpan(location, location); const error = new ParseError(span, '**ERROR**'); expect(error.toString()).toEqual( `**ERROR** ("\n222\n333\n[ERROR ->]E\n444\n555\n"): file://@123:456`, ); }); }); describe('unicode characters', () => { it('should support unicode characters', () => { expect(tokenizeAndHumanizeSourceSpans(`<p>İ</p>`)).toEqual([ [TokenType.TAG_OPEN_START, '<p'], [TokenType.TAG_OPEN_END, '>'], [TokenType.TEXT, 'İ'], [TokenType.TAG_CLOSE, '</p>'], [TokenType.EOF, ''], ]); }); }); describe('(processing escaped strings)', () => { it('should unescape standard escape sequences', () => { expect(tokenizeAndHumanizeParts("\\' \\' \\'", {escapedString: true})).toEqual([ [TokenType.TEXT, "' ' '"], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\" \\" \\"', {escapedString: true})).toEqual([ [TokenType.TEXT, '" " "'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\` \\` \\`', {escapedString: true})).toEqual([ [TokenType.TEXT, '` ` `'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\\\ \\\\ \\\\', {escapedString: true})).toEqual([ [TokenType.TEXT, '\\ \\ \\'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\n \\n \\n', {escapedString: true})).toEqual([ [TokenType.TEXT, '\n \n \n'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\r{{\\r}}\\r', {escapedString: true})).toEqual([ // post processing converts `\r` to `\n` [TokenType.TEXT, '\n'], [TokenType.INTERPOLATION, '{{', '\n', '}}'], [TokenType.TEXT, '\n'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\v \\v \\v', {escapedString: true})).toEqual([ [TokenType.TEXT, '\v \v \v'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\t \\t \\t', {escapedString: true})).toEqual([ [TokenType.TEXT, '\t \t \t'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\b \\b \\b', {escapedString: true})).toEqual([ [TokenType.TEXT, '\b \b \b'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\f \\f \\f', {escapedString: true})).toEqual([ [TokenType.TEXT, '\f \f \f'], [TokenType.EOF], ]); expect( tokenizeAndHumanizeParts('\\\' \\" \\` \\\\ \\n \\r \\v \\t \\b \\f', { escapedString: true, }), ).toEqual([[TokenType.TEXT, '\' " ` \\ \n \n \v \t \b \f'], [TokenType.EOF]]); }); it('should unescape null sequences', () => { expect(tokenizeAndHumanizeParts('\\0', {escapedString: true})).toEqual([[TokenType.EOF]]); // \09 is not an octal number so the \0 is taken as EOF expect(tokenizeAndHumanizeParts('\\09', {escapedString: true})).toEqual([[TokenType.EOF]]); }); it('should unescape octal sequences', () => { // \19 is read as an octal `\1` followed by a normal char `9` // \1234 is read as an octal `\123` followed by a normal char `4` // \999 is not an octal number so its backslash just gets removed. expect( tokenizeAndHumanizeParts('\\001 \\01 \\1 \\12 \\223 \\19 \\2234 \\999', { escapedString: true, }), ).toEqual([[TokenType.TEXT, '\x01 \x01 \x01 \x0A \x93 \x019 \x934 999'], [TokenType.EOF]]); }); it('should unescape hex sequences', () => { expect(tokenizeAndHumanizeParts('\\x12 \\x4F \\xDC', {escapedString: true})).toEqual([ [TokenType.TEXT, '\x12 \x4F \xDC'], [TokenType.EOF], ]); }); it('should report an error on an invalid hex sequence', () => { expect(tokenizeAndHumanizeErrors('\\xGG', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:2'], ]); expect(tokenizeAndHumanizeErrors('abc \\x xyz', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:6'], ]); expect(tokenizeAndHumanizeErrors('abc\\x', {escapedString: true})).toEqual([ ['Unexpected character "EOF"', '0:5'], ]); }); it('should unescape fixed length Unicode sequences', () => { expect(tokenizeAndHumanizeParts('\\u0123 \\uABCD', {escapedString: true})).toEqual([ [TokenType.TEXT, '\u0123 \uABCD'], [TokenType.EOF], ]); }); it('should error on an invalid fixed length Unicode sequence', () => { expect(tokenizeAndHumanizeErrors('\\uGGGG', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:2'], ]); }); it('should unescape variable length Unicode sequences', () => { expect( tokenizeAndHumanizeParts('\\u{01} \\u{ABC} \\u{1234} \\u{123AB}', {escapedString: true}), ).toEqual([[TokenType.TEXT, '\u{01} \u{ABC} \u{1234} \u{123AB}'], [TokenType.EOF]]); }); it('should error on an invalid variable length Unicode sequence', () => { expect(tokenizeAndHumanizeErrors('\\u{GG}', {escapedString: true})).toEqual([ ['Invalid hexadecimal escape sequence', '0:3'], ]); }); it('should unescape line continuations', () => { expect(tokenizeAndHumanizeParts('abc\\\ndef', {escapedString: true})).toEqual([ [TokenType.TEXT, 'abcdef'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('\\\nx\\\ny\\\n', {escapedString: true})).toEqual([ [TokenType.TEXT, 'xy'], [TokenType.EOF], ]); }); it('should remove backslash from "non-escape" sequences', () => { expect(tokenizeAndHumanizeParts('a g ~', {escapedString: true})).toEqual([ [TokenType.TEXT, 'a g ~'], [TokenType.EOF], ]); }); it('should unescape sequences in plain text', () => { expect( tokenizeAndHumanizeParts('abc\ndef\\nghi\\tjkl\\`\\\'\\"mno', {escapedString: true}), ).toEqual([[TokenType.TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.EOF]]); }); it('should unescape sequences in raw text', () => { expect( tokenizeAndHumanizeParts('<script>abc\ndef\\nghi\\tjkl\\`\\\'\\"mno</script>', { escapedString: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'script'], [TokenType.TAG_OPEN_END], [TokenType.RAW_TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.TAG_CLOSE, '', 'script'], [TokenType.EOF], ]); }); it('should unescape sequences in escapable raw text', () => { expect( tokenizeAndHumanizeParts('<title>abc\ndef\\nghi\\tjkl\\`\\\'\\"mno', { escapedString: true, }), ).toEqual([ [TokenType.TAG_OPEN_START, '', 'title'], [TokenType.TAG_OPEN_END], [TokenType.ESCAPABLE_RAW_TEXT, 'abc\ndef\nghi\tjkl`\'"mno'], [TokenType.TAG_CLOSE, '', 'title'], [TokenType.EOF], ]); }); it('should parse over escape sequences in tag definitions', () => { expect( tokenizeAndHumanizeParts('', {escapedString: true}), ).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'c'], [TokenType.ATTR_QUOTE, "'"], [TokenType.ATTR_VALUE_TEXT, 'd'], [TokenType.ATTR_QUOTE, "'"], [TokenType.TAG_OPEN_END], [TokenType.EOF], ]); }); it('should parse over escaped new line in tag definitions', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should parse over escaped characters in tag definitions', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should unescape characters in tag names', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 'td'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'td'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); it('should unescape characters in attributes', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'd'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, 'e'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should parse over escaped new line in attribute values', () => { const text = ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_VALUE_TEXT, 'b'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); }); it('should tokenize the correct span when there are escape sequences', () => { const text = 'selector: "app-root",\ntemplate: "line 1\\n\\"line 2\\"\\nline 3",\ninputs: []'; const range = { startPos: 33, startLine: 1, startCol: 10, endPos: 59, }; expect(tokenizeAndHumanizeParts(text, {range, escapedString: true})).toEqual([ [TokenType.TEXT, 'line 1\n"line 2"\nline 3'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeSourceSpans(text, {range, escapedString: true})).toEqual([ [TokenType.TEXT, 'line 1\\n\\"line 2\\"\\nline 3'], [TokenType.EOF, ''], ]); }); it('should account for escape sequences when computing source spans ', () => { const text = 'line 1\n' + // <- unescaped line break 'line 2\\n' + // <- escaped line break 'line 3\\\n' + // <- line continuation ''; expect(tokenizeAndHumanizeParts(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 1'], [TokenType.TAG_CLOSE, '', 't'], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 2'], [TokenType.TAG_CLOSE, '', 't'], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, '', 't'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'line 3'], // <- line continuation does not appear in token [TokenType.TAG_CLOSE, '', 't'], [TokenType.EOF], ]); expect(tokenizeAndHumanizeLineColumn(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, '0:0'], [TokenType.TAG_OPEN_END, '0:2'], [TokenType.TEXT, '0:3'], [TokenType.TAG_CLOSE, '0:9'], [TokenType.TEXT, '0:13'], // <- real newline increments the row [TokenType.TAG_OPEN_START, '1:0'], [TokenType.TAG_OPEN_END, '1:2'], [TokenType.TEXT, '1:3'], [TokenType.TAG_CLOSE, '1:9'], [TokenType.TEXT, '1:13'], // <- escaped newline does not increment the row [TokenType.TAG_OPEN_START, '1:15'], [TokenType.TAG_OPEN_END, '1:17'], [TokenType.TEXT, '1:18'], // <- the line continuation increments the row [TokenType.TAG_CLOSE, '2:0'], [TokenType.EOF, '2:4'], ]); expect(tokenizeAndHumanizeSourceSpans(text, {escapedString: true})).toEqual([ [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 1'], [TokenType.TAG_CLOSE, ''], [TokenType.TEXT, '\n'], [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 2'], [TokenType.TAG_CLOSE, ''], [TokenType.TEXT, '\\n'], [TokenType.TAG_OPEN_START, ''], [TokenType.TEXT, 'line 3\\\n'], [TokenType.TAG_CLOSE, ''], [TokenType.EOF, ''], ]); }); }); describe('blocks', () => { it('should parse a block without parameters', () => { const expected = [ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]; expect(tokenizeAndHumanizeParts('@if {hello}')).toEqual(expected); expect(tokenizeAndHumanizeParts('@if () {hello}')).toEqual(expected); expect(tokenizeAndHumanizeParts('@if(){hello}')).toEqual(expected); }); it('should parse @default never;', () => { expect(tokenizeAndHumanizeParts('@default never;')).toEqual([ [TokenType.BLOCK_OPEN_START, 'default never'], [TokenType.BLOCK_OPEN_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse @default never ;', () => { expect(tokenizeAndHumanizeParts('@default never ;')).toEqual([ [TokenType.BLOCK_OPEN_START, 'default never'], [TokenType.BLOCK_OPEN_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with parameters', () => { expect(tokenizeAndHumanizeParts('@for (item of items; track item.id) {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, 'item of items'], [TokenType.BLOCK_PARAMETER, 'track item.id'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with a trailing semicolon after the parameters', () => { expect(tokenizeAndHumanizeParts('@for (item of items;) {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, 'item of items'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with a space in its name', () => { expect(tokenizeAndHumanizeParts('@else if {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'else if'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); expect(tokenizeAndHumanizeParts('@else if (foo !== 2) {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'else if'], [TokenType.BLOCK_PARAMETER, 'foo !== 2'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with an arbitrary amount of spaces around the parentheses', () => { const expected = [ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, 'a'], [TokenType.BLOCK_PARAMETER, 'b'], [TokenType.BLOCK_PARAMETER, 'c'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]; expect(tokenizeAndHumanizeParts('@for(a; b; c){hello}')).toEqual(expected); expect(tokenizeAndHumanizeParts('@for (a; b; c) {hello}')).toEqual(expected); expect(tokenizeAndHumanizeParts('@for(a; b; c) {hello}')).toEqual(expected); expect(tokenizeAndHumanizeParts('@for (a; b; c){hello}')).toEqual(expected); }); it('should parse a block with multiple trailing semicolons', () => { expect(tokenizeAndHumanizeParts('@for (item of items;;;;;) {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, 'item of items'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with trailing whitespace', () => { expect(tokenizeAndHumanizeParts('@defer {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'defer'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block with no trailing semicolon', () => { expect(tokenizeAndHumanizeParts('@for (item of items){hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, 'item of items'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should handle semicolons, braces and parentheses used in a block parameter', () => { const input = `@for (a === ";"; b === ')'; c === "("; d === '}'; e === "{") {hello}`; expect(tokenizeAndHumanizeParts(input)).toEqual([ [TokenType.BLOCK_OPEN_START, 'for'], [TokenType.BLOCK_PARAMETER, `a === ";"`], [TokenType.BLOCK_PARAMETER, `b === ')'`], [TokenType.BLOCK_PARAMETER, `c === "("`], [TokenType.BLOCK_PARAMETER, `d === '}'`], [TokenType.BLOCK_PARAMETER, `e === "{"`], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should handle object literals and function calls in block parameters', () => { expect( tokenizeAndHumanizeParts( `@defer (on a({a: 1, b: 2}, false, {c: 3}); when b({d: 4})) {hello}`, ), ).toEqual([ [TokenType.BLOCK_OPEN_START, 'defer'], [TokenType.BLOCK_PARAMETER, 'on a({a: 1, b: 2}, false, {c: 3})'], [TokenType.BLOCK_PARAMETER, 'when b({d: 4})'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse block with unclosed parameters', () => { expect(tokenizeAndHumanizeParts(`@if (a === b {hello}`)).toEqual([ [TokenType.INCOMPLETE_BLOCK_OPEN, 'if'], [TokenType.BLOCK_PARAMETER, 'a === b {hello}'], [TokenType.EOF], ]); }); it('should parse block with stray parentheses in the parameter position', () => { expect(tokenizeAndHumanizeParts(`@if a === b) {hello}`)).toEqual([ [TokenType.INCOMPLETE_BLOCK_OPEN, 'if a'], [TokenType.TEXT, '=== b) {hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should report invalid quotes in a parameter', () => { expect(tokenizeAndHumanizeErrors(`@if (a === ") {hello}`)).toEqual([ ['Unexpected character "EOF"', '0:21'], ]); expect(tokenizeAndHumanizeErrors(`@if (a === "hi') {hello}`)).toEqual([ ['Unexpected character "EOF"', '0:24'], ]); }); it('should report unclosed object literal inside a parameter', () => { expect(tokenizeAndHumanizeParts(`@if ({invalid: true) hello}`)).toEqual([ [TokenType.INCOMPLETE_BLOCK_OPEN, 'if'], [TokenType.BLOCK_PARAMETER, '{invalid: true'], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should handle a semicolon used in a nested string inside a block parameter', () => { expect(tokenizeAndHumanizeParts(`@if (condition === "';'") {hello}`)).toEqual([ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, `condition === "';'"`], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should handle a semicolon next to an escaped quote used in a block parameter', () => { expect(tokenizeAndHumanizeParts('@if (condition === "\\";") {hello}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'condition === "\\";"'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse mixed text and html content in a block', () => { expect(tokenizeAndHumanizeParts('@if (a === 1) {foo bar baz}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'a === 1'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'foo '], [TokenType.TAG_OPEN_START, '', 'b'], [TokenType.TAG_OPEN_END], [TokenType.TEXT, 'bar'], [TokenType.TAG_CLOSE, '', 'b'], [TokenType.TEXT, ' baz'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse HTML tags with attributes containing curly braces inside blocks', () => { expect(tokenizeAndHumanizeParts('@if (a === 1) {
}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'a === 1'], [TokenType.BLOCK_OPEN_END], [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '}'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_NAME, '', 'b'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '{'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse HTML tags with attribute containing block syntax', () => { expect(tokenizeAndHumanizeParts('
')).toEqual([ [TokenType.TAG_OPEN_START, '', 'div'], [TokenType.ATTR_NAME, '', 'a'], [TokenType.ATTR_QUOTE, '"'], [TokenType.ATTR_VALUE_TEXT, '@if (foo) {}'], [TokenType.ATTR_QUOTE, '"'], [TokenType.TAG_OPEN_END], [TokenType.TAG_CLOSE, '', 'div'], [TokenType.EOF], ]); }); it('should parse nested blocks', () => { expect( tokenizeAndHumanizeParts( '@if (a) {' + 'hello a' + '@if {' + 'hello unnamed' + '@if (b) {' + 'hello b' + '@if (c) {' + 'hello c' + '}' + '}' + '}' + '}', ), ).toEqual([ [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'a'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello a'], [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello unnamed'], [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'b'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello b'], [TokenType.BLOCK_OPEN_START, 'if'], [TokenType.BLOCK_PARAMETER, 'c'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, 'hello c'], [TokenType.BLOCK_CLOSE], [TokenType.BLOCK_CLOSE], [TokenType.BLOCK_CLOSE], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block containing an expansion', () => { const result = tokenizeAndHumanizeParts( '@defer {{one.two, three, =4 {four} =5 {five} foo {bar} }}', {tokenizeExpansionForms: true}, ); expect(result).toEqual([ [TokenType.BLOCK_OPEN_START, 'defer'], [TokenType.BLOCK_OPEN_END], [TokenType.EXPANSION_FORM_START], [TokenType.RAW_TEXT, 'one.two'], [TokenType.RAW_TEXT, 'three'], [TokenType.EXPANSION_CASE_VALUE, '=4'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'four'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, '=5'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'five'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_CASE_VALUE, 'foo'], [TokenType.EXPANSION_CASE_EXP_START], [TokenType.TEXT, 'bar'], [TokenType.EXPANSION_CASE_EXP_END], [TokenType.EXPANSION_FORM_END], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse a block containing an interpolation', () => { expect(tokenizeAndHumanizeParts('@defer {{{message}}}')).toEqual([ [TokenType.BLOCK_OPEN_START, 'defer'], [TokenType.BLOCK_OPEN_END], [TokenType.TEXT, ''], [TokenType.INTERPOLATION, '{{', 'message', '}}'], [TokenType.TEXT, ''], [TokenType.BLOCK_CLOSE], [TokenType.EOF], ]); }); it('should parse an incomplete block start without parameters with surrounding text', () => { expect(tokenizeAndHumanizeParts('My email frodo@for.com')).toEqual([ [TokenType.TEXT, 'My email frodo'], [TokenType.INCOMPLETE_BLOCK_OPEN, 'for'], [TokenType.TEXT, '.com'], [TokenType.EOF], ]); }); it('should parse an incomplete block start at the end of the input', () => { expect(tokenizeAndHumanizeParts('My favorite console is @switch')).toEqual([ [TokenType.TEXT, 'My favorite console is '], [TokenType.INCOMPLETE_BLOCK_OPEN, 'switch'], [TokenType.EOF], ]); }); it('should parse an incomplete block start with parentheses but without params', () => { expect(tokenizeAndHumanizeParts('Use the @for() block')).toEqual([ [TokenType.TEXT, 'Use the '], [TokenType.INCOMPLETE_BLOCK_OPEN, 'for'], [TokenType.TEXT, 'block'], [TokenType.EOF], ]); }); it('should parse an incomplete block start with parentheses and params', () => { expect(tokenizeAndHumanizeParts('This is the @if({alias: "foo"}) expression')).toEqual([ [TokenType.TEXT, 'This is the '], [TokenType.INCOMPLETE_BLOCK_OPEN, 'if'], [TokenType.BLOCK_PARAMETER, '{alias: "foo"}'], [TokenType.TEXT, 'expression'], [TokenType.EOF], ]); }); it('should parse @ as text', () => { expect(tokenizeAndHumanizeParts(`@`)).toEqual([[TokenType.TEXT, '@'], [TokenType.EOF]]); }); it('should parse space followed by @ as text', () => { expect(tokenizeAndHumanizeParts(` @`)).toEqual([[TokenType.TEXT, ' @'], [TokenType.EOF]]); }); it('should parse @ followed by space as text', () => { expect(tokenizeAndHumanizeParts(`@ `)).toEqual([[TokenType.TEXT, '@ '], [TokenType.EOF]]); }); it('should parse @ followed by newline and text as text', () => { expect(tokenizeAndHumanizeParts(`@\nfoo`)).toEqual([ [TokenType.TEXT, '@\nfoo'], [TokenType.EOF], ]); }); it('should parse @ in the middle of text as text', () => { expect(tokenizeAndHumanizeParts(`foo bar @ baz clink`)).toEqual([ [TokenType.TEXT, 'foo bar @ baz clink'], [TokenType.EOF], ]); }); it('should parse incomplete block with space, then name as text', () => { expect(tokenizeAndHumanizeParts(`@ if`)).toEqual([[TokenType.TEXT, '@ if'], [TokenType.EOF]]); }); }); }); function tokenizeWithoutErrors(input: string, options?: TokenizeOptions): TokenizeResult { const tokenizeResult = tokenize(input, 'someUrl', getHtmlTagDefinition, options); if (tokenizeResult.errors.length > 0) { const errorString = tokenizeResult.errors.join('\n'); throw new Error(`Unexpected parse errors:\n${errorString}`); } return tokenizeResult; } function humanizeParts(tokens: Token[]) { return tokens.map((token) => [token.type, ...token.parts]); } function tokenizeAndHumanizeParts(input: string, options?: TokenizeOptions): any[] { return humanizeParts(tokenizeWithoutErrors(input, options).tokens); } function tokenizeAndHumanizeSourceSpans(input: string, options?: TokenizeOptions): any[] { return tokenizeWithoutErrors(input, options).tokens.map((token) => [ token.type, token.sourceSpan.toString(), ]); } function humanizeLineColumn(location: ParseLocation): string { return `${location.line}:${location.col}`; } function tokenizeAndHumanizeLineColumn(input: string, options?: TokenizeOptions): any[] { return tokenizeWithoutErrors(input, options).tokens.map((token) => [ token.type, humanizeLineColumn(token.sourceSpan.start), ]); } function tokenizeAndHumanizeFullStart(input: string, options?: TokenizeOptions): any[] { return tokenizeWithoutErrors(input, options).tokens.map((token) => [ token.type, humanizeLineColumn(token.sourceSpan.start), humanizeLineColumn(token.sourceSpan.fullStart), ]); } function tokenizeAndHumanizeErrors(input: string, options?: TokenizeOptions): any[] { return tokenize(input, 'someUrl', getHtmlTagDefinition, options).errors.map((e) => [ e.msg, humanizeLineColumn(e.span.start), ]); }