import { AttributeDelimiterMark, TextNodeDelimiterMark } from "src/compilation/delimiters/delimiterMark"; import { AttributeTag, TagDisposition, TagPlacement } from "src/compilation/tag"; import { TagParser } from "src/compilation/tagParser"; import { Delimiters } from "src/delimiters"; import { MissingCloseDelimiterError, MissingStartDelimiterError, TagOptionsParseError } from "src/errors"; import { xml, XmlNodeType, XmlTextNode } from "src/xml"; import { parseXml } from "test/testUtils"; import { describe, expect, test } from "vitest"; describe(TagParser, () => { describe('text node delimiters', () => { test('trim tag names', () => { const paragraph = parseXml(` {# my loop }{ my tag }{/ my loop } `, false); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); expect(textNode.textContent).toEqual('{# my loop }{ my tag }{/ my loop }'); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 12, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 13, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 23, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 24, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 36, xmlTextNode: textNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(3); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('my loop'); expect(tags[0].rawText).toEqual('{# my loop }'); // middle tag expect(tags[1].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[1].name).toEqual('my tag'); expect(tags[1].rawText).toEqual('{ my tag }'); // close tag expect(tags[2].disposition).toEqual(TagDisposition.Close); expect(tags[2].name).toEqual('my loop'); expect(tags[2].rawText).toEqual('{/ my loop }'); }); test('multiple tags on the same text node', () => { const paragraph = parseXml(` {#loop}{/loop} `); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 6, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 7, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 13, xmlTextNode: textNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].rawText).toEqual('{#loop}'); // close tag expect(tags[1].disposition).toEqual(TagDisposition.Close); expect(tags[1].name).toEqual('loop'); expect(tags[1].rawText).toEqual('{/loop}'); }); test('multiple tags on the same text node, with leading text', () => { const paragraph = parseXml(` text1{#loop}text2{/loop} `); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 5, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 11, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 17, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 23, xmlTextNode: textNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].rawText).toEqual('{#loop}'); // close tag expect(tags[1].disposition).toEqual(TagDisposition.Close); expect(tags[1].name).toEqual('loop'); expect(tags[1].rawText).toEqual('{/loop}'); }); test('parse a butterfly }{', () => { const paragraphNode = parseXml(` {#loop }{ /loop} `); const runNode = paragraphNode.childNodes[0]; const firstTextNode = xml.query.findByPath(runNode, XmlNodeType.Text, 0, 0); const secondTextNode = xml.query.findByPath(runNode, XmlNodeType.Text, 1, 0); const thirdTextNode = xml.query.findByPath(runNode, XmlNodeType.Text, 2, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 0, xmlTextNode: secondTextNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 1, xmlTextNode: secondTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 5, xmlTextNode: thirdTextNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].rawText).toEqual('{#loop}'); // close tag expect(tags[1].disposition).toEqual(TagDisposition.Close); expect(tags[1].name).toEqual('loop'); expect(tags[1].rawText).toEqual('{/loop}'); }); test('splitted simple tag', () => { const paragraphNode = parseXml(` {#loo p} `); const runNode = xml.query.findByPath(paragraphNode, XmlNodeType.General, 0); const firstTextNode = xml.query.findByPath(runNode, XmlNodeType.Text, 0, 0); const secondTextNode = xml.query.findByPath(runNode, XmlNodeType.Text, 1, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 1, xmlTextNode: secondTextNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(1); // tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].rawText).toEqual('{#loop}'); }); test('splitted closing tag', () => { const paragraphNode = parseXml(` {#loop}{text}{/ loop } `); const firstTextNode = xml.query.findByPath(paragraphNode, XmlNodeType.Text, 0, 0, 0); expect(firstTextNode.textContent).toEqual('{#loop}{text}{/'); const thirdTextNode = xml.query.findByPath(paragraphNode, XmlNodeType.Text, 2, 0, 0); expect(thirdTextNode.textContent).toEqual('}'); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 6, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 7, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 12, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 13, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 0, xmlTextNode: thirdTextNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags).toHaveLength(3); expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].rawText).toEqual('{#loop}'); expect(tags[1].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[1].name).toEqual('text'); expect(tags[1].rawText).toEqual('{text}'); expect(tags[2].disposition).toEqual(TagDisposition.Close); expect(tags[2].name).toEqual('loop'); expect(tags[2].rawText).toEqual('{/loop}'); }); test('close delimiter in different paragraph throws MissingCloseDelimiterError', () => { const body = parseXml(` {my_simple_ tag} `, false); const firstParagraph = body.childNodes.find(node => node.nodeName === 'w:p'); const secondParagraph = body.childNodes.findLast(node => node.nodeName === 'w:p'); const firstRun = firstParagraph.childNodes[0]; const secondRun = secondParagraph.childNodes[0]; const firstTextNode = firstRun.childNodes[0] as XmlTextNode; const secondTextNode = secondRun.childNodes[0] as XmlTextNode; const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: firstTextNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 4, xmlTextNode: secondTextNode } ]; const parser = createTagParser(); expect(() => parser.parse(delimiters)).toThrow(MissingCloseDelimiterError); }); }); describe('attribute delimiters', () => { test('simple attribute tag', () => { const paragraph = parseXml(` `, false); const docPrNode = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0, 0, 0); expect(docPrNode.attributes.descr).toEqual('{my_tag}'); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: true, index: 0, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 7, xmlNode: docPrNode, attributeName: 'descr' } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(1); const tag = tags[0] as AttributeTag; expect(tag.disposition).toEqual(TagDisposition.SelfClosed); expect(tag.name).toEqual('my_tag'); expect(tag.rawText).toEqual('{my_tag}'); expect(tag.placement).toEqual(TagPlacement.Attribute); expect(tag.xmlNode).toEqual(docPrNode); expect(tag.attributeName).toEqual('descr'); }); test('open and close tags in attribute', () => { const paragraph = parseXml(` `, false); const docPrNode = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0); expect(docPrNode.attributes.descr).toEqual('{#loop}content{/loop}'); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: true, index: 0, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 6, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: true, index: 14, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 20, xmlNode: docPrNode, attributeName: 'descr' } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag const openTag = tags[0] as AttributeTag; expect(openTag.disposition).toEqual(TagDisposition.Open); expect(openTag.name).toEqual('loop'); expect(openTag.rawText).toEqual('{#loop}'); expect(openTag.placement).toEqual(TagPlacement.Attribute); // close tag const closeTag = tags[1] as AttributeTag; expect(closeTag.disposition).toEqual(TagDisposition.Close); expect(closeTag.name).toEqual('loop'); expect(closeTag.rawText).toEqual('{/loop}'); expect(closeTag.placement).toEqual(TagPlacement.Attribute); }); test('multiple self-closed tags in attribute', () => { const paragraph = parseXml(` `, false); const docPrNode = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0); expect(docPrNode.attributes.descr).toEqual('{tag1}{tag2}{tag3}'); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: true, index: 0, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 5, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: true, index: 6, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 11, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: true, index: 12, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 17, xmlNode: docPrNode, attributeName: 'descr' } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(3); expect(tags[0].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[0].name).toEqual('tag1'); expect(tags[0].rawText).toEqual('{tag1}'); expect(tags[1].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[1].name).toEqual('tag2'); expect(tags[1].rawText).toEqual('{tag2}'); expect(tags[2].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[2].name).toEqual('tag3'); expect(tags[2].rawText).toEqual('{tag3}'); }); test('tag with options in attribute', () => { const paragraph = parseXml(` `, false); const docPrNode = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0); expect(docPrNode.attributes.descr).toEqual("{my_tag [opt: 'value', count: 5]}"); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: true, index: 0, xmlNode: docPrNode, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 32, xmlNode: docPrNode, attributeName: 'descr' } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(1); expect(tags[0].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[0].name).toEqual('my_tag'); expect(tags[0].options).toEqual({ opt: 'value', count: 5 }); expect(tags[0].rawText).toEqual("{my_tag [opt: 'value', count: 5]}"); }); test('missing open delimiter in attribute throws MissingStartDelimiterError', () => { const paragraph = parseXml(` `, false); const docPrNode = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0); expect(docPrNode.attributes.descr).toEqual('my_tag}'); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: false, index: 6, xmlNode: docPrNode, attributeName: 'descr' } ]; const parser = createTagParser(); expect(() => parser.parse(delimiters)).toThrow(MissingStartDelimiterError); }); test('open and close tags in different attributes throws MissingCloseDelimiterError', () => { const paragraph = parseXml(` `, false); const docPrNode1 = xml.query.findByPath(paragraph, XmlNodeType.General, 0, 0, 0, 0); expect(docPrNode1.attributes.descr).toEqual('{my_tag'); const docPrNode2 = xml.query.findByPath(paragraph, XmlNodeType.General, 1, 0, 0, 0); expect(docPrNode2.attributes.descr).toEqual('}'); const delimiters: AttributeDelimiterMark[] = [ { placement: TagPlacement.Attribute, isOpen: true, index: 0, xmlNode: docPrNode1, attributeName: 'descr' }, { placement: TagPlacement.Attribute, isOpen: false, index: 0, xmlNode: docPrNode2, attributeName: 'descr' } ]; const parser = createTagParser(); expect(() => parser.parse(delimiters)).toThrow(MissingCloseDelimiterError); }); }); describe('tag options', () => { test('simple', () => { const text = '{#loop [opt: "yes"]}{/loop}'; const paragraph = parseXml(` ${text} `, false); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 19, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 20, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 26, xmlTextNode: textNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].options).toEqual({ opt: 'yes' }); expect(tags[0].rawText).toEqual('{#loop [opt: "yes"]}'); // close tag expect(tags[1].disposition).toEqual(TagDisposition.Close); expect(tags[1].name).toEqual('loop'); expect(tags[1].options).toBeFalsy(); expect(tags[1].rawText).toEqual('{/loop}'); }); test('simple - with whitespace', () => { const text = '{ # loop [opt: "yes"] }{ / loop }'; const paragraph = parseXml(` ${text} `, false); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 22, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: true, index: 23, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 32, xmlTextNode: textNode } ]; const parser = createTagParser(); const tags = parser.parse(delimiters); expect(tags.length).toEqual(2); // open tag expect(tags[0].disposition).toEqual(TagDisposition.Open); expect(tags[0].name).toEqual('loop'); expect(tags[0].options).toEqual({ opt: 'yes' }); expect(tags[0].rawText).toEqual('{ # loop [opt: "yes"] }'); // close tag expect(tags[1].disposition).toEqual(TagDisposition.Close); expect(tags[1].name).toEqual('loop'); expect(tags[1].options).toBeFalsy(); expect(tags[1].rawText).toEqual('{ / loop }'); }); test('angular parser style with brackets', () => { const text = '{something[0] [[myOpt: 5]]}'; const paragraph = parseXml(` ${text} `, false); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 26, xmlTextNode: textNode } ]; const parser = createTagParser({ tagOptionsStart: '[[', tagOptionsEnd: ']]' }); const tags = parser.parse(delimiters); expect(tags.length).toEqual(1); expect(tags[0].disposition).toEqual(TagDisposition.SelfClosed); expect(tags[0].name).toEqual('something[0]'); expect(tags[0].options).toEqual({ myOpt: 5 }); expect(tags[0].rawText).toEqual('{something[0] [[myOpt: 5]]}'); }); test('invalid options', () => { const text = '{something [myOpt 5]}'; const paragraph = parseXml(` ${text} `, false); const textNode = xml.query.findByPath(paragraph, XmlNodeType.Text, 0, 0, 0); const delimiters: TextNodeDelimiterMark[] = [ { placement: TagPlacement.TextNode, isOpen: true, index: 0, xmlTextNode: textNode }, { placement: TagPlacement.TextNode, isOpen: false, index: 20, xmlTextNode: textNode } ]; const parser = createTagParser(); let err: Error; try { parser.parse(delimiters); } catch (e) { err = e; } expect(err).toBeTruthy(); expect(err).toBeInstanceOf(TagOptionsParseError); const parseErr = err as TagOptionsParseError; expect(parseErr.tagRawText).toEqual(text); expect(parseErr.parseError).toBeTruthy(); }); }); }); function createTagParser(delim?: Partial): TagParser { const delimiters = new Delimiters(delim); return new TagParser(delimiters); }