');
+ expectPath(ast, 'children.1.type').to.eql('LiquidVariableOutput');
+ expectPath(ast, 'children.2.type').to.eql('TextNode');
+ expectPath(ast, 'children.2.value').to.eql('
');
+ });
+
+ it('should collapse HTML comments into text', () => {
+ const ast = toLiquidAST('');
+ expectPath(ast, 'children').to.have.lengthOf(1);
+ expectPath(ast, 'children.0.type').to.eql('TextNode');
+ expectPath(ast, 'children.0.value').to.eql('');
+ });
+
+ it('should collapse DOCTYPE into text', () => {
+ const ast = toLiquidAST('');
+ expectPath(ast, 'children').to.have.lengthOf(1);
+ expectPath(ast, 'children.0.type').to.eql('TextNode');
+ expectPath(ast, 'children.0.value').to.eql('');
+ });
+
+ it('should collapse self-closing tags into text', () => {
+ const ast = toLiquidAST(' {% if true %}');
+ expectPath(ast, 'children.0.type').to.eql('LiquidTag');
+ expectPath(ast, 'children.0.name').to.eql('for');
+ // Inside for: TextNode + if tag
+ expectPath(ast, 'children.0.children.0.children.0.type').to.eql('TextNode');
+ expectPath(ast, 'children.0.children.0.children.1.type').to.eql('LiquidTag');
+ expectPath(ast, 'children.0.children.0.children.1.name').to.eql('if');
+ });
+
+ it('should tolerate unclosed Liquid tags in {% liquid %} blocks', () => {
+ const ast = toLiquidAST(`{%- liquid
+ assign var1 = product
+ if use_variant
+ assign var2 = var1
+-%}`);
+ expectPath(ast, 'children.0.type').to.eql('LiquidTag');
+ expectPath(ast, 'children.0.name').to.eql('liquid');
+ // Verify inner structure is properly parsed (not base case fallback)
+ expectPath(ast, 'children.0.markup.0.name').to.eql('assign');
+ expectPath(ast, 'children.0.markup.1.name').to.eql('if');
+ });
+
+ it('should parse raw Liquid tags normally (doc, comment, raw)', () => {
+ const ast = toLiquidAST('{% doc %}@param x{% enddoc %}');
+ expectPath(ast, 'children.0.type').to.eql('LiquidRawTag');
+ expectPath(ast, 'children.0.name').to.eql('doc');
+ });
+
+ it('should handle script/style tags with Liquid as text nodes', () => {
+ const source = '';
+ const ast = toLiquidAST(source);
+ // First child is text up to the Liquid drop (trailing space trimmed at non-text boundary)
+ expectPath(ast, 'children.0.type').to.eql('TextNode');
+ expectPath(ast, 'children.0.value').to.eql('
+ expectPath(ast, 'children.2.type').to.eql('TextNode');
+ expectPath(ast, 'children.2.value').to.eql(';\n');
+ });
+
+ it('should maintain position accuracy for merged text nodes', () => {
+ const source = '
hello
';
+ const ast = toLiquidAST(source);
+ expectPath(ast, 'children').to.have.lengthOf(1);
+ expectPath(ast, 'children.0.type').to.eql('TextNode');
+ // In non-HTML mode, leading/trailing whitespace is trimmed from text at document root
+ expectPosition(ast, 'children.0').to.eql('
hello
');
+ });
+
+ it('should parse YAML frontmatter normally', () => {
+ const ast = toLiquidAST('---\ntitle: test\n---\n
{{ x }}
');
+ expectPath(ast, 'children.0.type').to.eql('YAMLFrontmatter');
+ expectPath(ast, 'children.1.type').to.eql('TextNode');
+ expectPath(ast, 'children.2.type').to.eql('LiquidVariableOutput');
+ });
+ });
+});
diff --git a/packages/liquid-html-parser/src/document/parser.ts b/packages/liquid-html-parser/src/document/parser.ts
new file mode 100644
index 000000000..aa2ba2836
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/parser.ts
@@ -0,0 +1,218 @@
+import { TokenType } from './tokenizer';
+import type { Token } from './tokenizer';
+import { ParserBase } from './base';
+import { makeDocumentNode } from './factories';
+import { ChildFilterMode, filterChildren } from './tree-builder';
+import type {
+ DocumentNode,
+ LiquidHtmlNode,
+ LiquidStatement,
+ LiquidVariableOutput,
+ LiquidRawTag,
+ LiquidTag,
+ LiquidNode,
+ TextNode,
+ HtmlElement,
+ HtmlVoidElement,
+ HtmlSelfClosingElement,
+ HtmlRawNode,
+ HtmlComment,
+ HtmlDoctype,
+ HtmlDanglingMarkerClose,
+ AttributeNode,
+} from '../ast';
+import '../environment'; // side-effect: ensures builtinTags init before downstream imports
+import type { Environment, LiquidLineContext } from '../environment';
+import type { Position } from '../types';
+import type { LiquidOpenWhitespace, LiquidCloseWhitespace } from './factories';
+import { parseLiquidVariableOutput as parseLiquidVariableOutputFn } from './liquid-variable-output';
+import {
+ peekTagName as peekTagNameFn,
+ isBlockTerminator as isBlockTerminatorFn,
+ consumeEndTag as consumeEndTagFn,
+} from './liquid-blocks';
+import type { BlockParserDelegate } from './liquid-blocks';
+import { parseLiquidInRange as parseLiquidInRangeFn } from './liquid-raw';
+import type { RawParserDelegate } from './liquid-raw';
+import { parseLiquidTag as parseLiquidTagFn } from './liquid-tags';
+import type { TagParserDelegate } from './liquid-tags';
+import {
+ parseHtmlElement as parseHtmlElementFn,
+ parseHtmlComment as parseHtmlCommentFn,
+ parseHtmlDoctype as parseHtmlDoctypeFn,
+ parseOrphanedHtmlCloseTag as parseOrphanedHtmlCloseTagFn,
+ parseHtmlDanglingMarkerClose as parseHtmlDanglingMarkerCloseFn,
+ parseBranchAttributesImpl as parseBranchAttributesFn,
+} from './html';
+import type { HtmlParserDelegate } from './html';
+import { parseLiquidStatement as parseLiquidStatementFn } from './liquid-lines';
+import type { LineParserDelegate } from './liquid-lines';
+import { parseNode as parseNodeFn } from './node-dispatch';
+import type { NodeDispatchDelegate } from './node-dispatch';
+
+export class DocumentParser
+ extends ParserBase
+ implements
+ TagParserDelegate,
+ BlockParserDelegate,
+ RawParserDelegate,
+ HtmlParserDelegate,
+ LineParserDelegate,
+ NodeDispatchDelegate
+{
+ private env: Environment;
+ private _parseHtml: boolean;
+ private _allowUnclosedHtml: boolean;
+ private readonly _allowUnclosedDocumentNode: boolean;
+ private _inAttributeContext: boolean = false;
+ private _inAttributeValueContext: boolean = false;
+
+ constructor(
+ tokens: Token[],
+ source: string,
+ env: Environment,
+ parseHtml: boolean,
+ allowUnclosedDocumentNode: boolean = false,
+ ) {
+ super(tokens, source);
+ this.env = env;
+ this._parseHtml = parseHtml;
+ this._allowUnclosedHtml = allowUnclosedDocumentNode;
+ this._allowUnclosedDocumentNode = allowUnclosedDocumentNode;
+ }
+
+ get rawParseHtml(): boolean {
+ return this._parseHtml;
+ }
+ set rawParseHtml(v: boolean) {
+ this._parseHtml = v;
+ }
+ get blockEnv(): Environment {
+ return this.env;
+ }
+ get blockParseHtml(): boolean {
+ return this._parseHtml;
+ }
+ get blockAllowUnclosedHtml(): boolean {
+ return this._allowUnclosedHtml;
+ }
+ set blockAllowUnclosedHtml(v: boolean) {
+ this._allowUnclosedHtml = v;
+ }
+ get blockAllowUnclosedDocumentNode(): boolean {
+ return this._allowUnclosedDocumentNode;
+ }
+ get blockInAttributeContext(): boolean {
+ return this._inAttributeContext;
+ }
+ get blockInAttributeValueContext(): boolean {
+ return this._inAttributeValueContext;
+ }
+ set blockInAttributeValueContext(v: boolean) {
+ this._inAttributeValueContext = v;
+ }
+ get htmlParseHtml(): boolean {
+ return this._parseHtml;
+ }
+ get htmlAllowUnclosedHtml(): boolean {
+ return this._allowUnclosedHtml;
+ }
+ set htmlAllowUnclosedHtml(v: boolean) {
+ this._allowUnclosedHtml = v;
+ }
+ get htmlInAttributeContext(): boolean {
+ return this._inAttributeContext;
+ }
+ set htmlInAttributeContext(v: boolean) {
+ this._inAttributeContext = v;
+ }
+ get htmlInAttributeValueContext(): boolean {
+ return this._inAttributeValueContext;
+ }
+ set htmlInAttributeValueContext(v: boolean) {
+ this._inAttributeValueContext = v;
+ }
+ get lineEnv(): Environment {
+ return this.env;
+ }
+ get lineParseHtml(): boolean {
+ return this._parseHtml;
+ }
+ get lineAllowUnclosedDocumentNode(): boolean {
+ return this._allowUnclosedDocumentNode;
+ }
+ get dispatchParseHtml(): boolean {
+ return this._parseHtml;
+ }
+ get dispatchAllowUnclosedHtml(): boolean {
+ return this._allowUnclosedHtml;
+ }
+
+ tagForName(name: string) {
+ return this.env.tagForName(name);
+ }
+
+ // document := yamlFrontmatter? node*
+ parseDocument(): DocumentNode {
+ const children: LiquidHtmlNode[] = [];
+ if (this.check(TokenType.YamlFrontmatter)) children.push(parseNodeFn(this));
+ while (!this.isAtEnd()) children.push(this.parseNode());
+ return makeDocumentNode(
+ filterChildren(ChildFilterMode.Syntactic, children, this.source),
+ this.source,
+ );
+ }
+
+ parseNode(): LiquidHtmlNode {
+ return parseNodeFn(this);
+ }
+ parseLiquidTag(): LiquidTag | LiquidRawTag {
+ return parseLiquidTagFn(this);
+ }
+ parseLiquidVariableOutput(): LiquidVariableOutput {
+ return parseLiquidVariableOutputFn(this);
+ }
+ peekTagName(): string | null {
+ return peekTagNameFn(this);
+ }
+ isBlockTerminator(): boolean {
+ return isBlockTerminatorFn(this);
+ }
+ consumeEndTag(): {
+ position: Position;
+ whitespace: { start: LiquidOpenWhitespace; end: LiquidCloseWhitespace };
+ } {
+ return consumeEndTagFn(this);
+ }
+ parseLiquidInRange(bodyStart: number, bodyEnd: number): (LiquidNode | TextNode)[] {
+ return parseLiquidInRangeFn(this, bodyStart, bodyEnd);
+ }
+ parseBranchAttributes(): AttributeNode[] {
+ return parseBranchAttributesFn(this);
+ }
+ parseLiquidStatement(
+ tagName: string,
+ markupString: string,
+ markupOffset: number,
+ ctx: LiquidLineContext,
+ ): LiquidStatement {
+ return parseLiquidStatementFn(this, tagName, markupString, markupOffset, ctx);
+ }
+ parseHtmlElement(): HtmlElement | HtmlVoidElement | HtmlSelfClosingElement | HtmlRawNode {
+ return parseHtmlElementFn(this);
+ }
+ parseHtmlComment(): HtmlComment {
+ return parseHtmlCommentFn(this);
+ }
+ parseHtmlDoctype(): HtmlDoctype {
+ return parseHtmlDoctypeFn(this);
+ }
+ parseOrphanedHtmlCloseTag(): never {
+ return parseOrphanedHtmlCloseTagFn(this);
+ }
+ parseHtmlDanglingMarkerClose(): HtmlDanglingMarkerClose {
+ return parseHtmlDanglingMarkerCloseFn(this);
+ }
+}
+
+export { finalizeBranch } from './liquid-blocks';
diff --git a/packages/liquid-html-parser/src/document/test-helpers.ts b/packages/liquid-html-parser/src/document/test-helpers.ts
new file mode 100644
index 000000000..4e81bdcae
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/test-helpers.ts
@@ -0,0 +1,29 @@
+import { expect } from 'vitest';
+import type { LiquidHtmlNode } from '../ast';
+import { deepGet } from '../utils';
+
+export function expectPath(ast: LiquidHtmlNode, path: string) {
+ return expect(deepGet(path.split('.'), ast));
+}
+
+export function expectPosition(ast: LiquidHtmlNode, path: string) {
+ const start = deepGet((path + '.position.start').split('.'), ast) as number;
+ const end = deepGet((path + '.position.end').split('.'), ast) as number;
+ return expect(ast.source.slice(start, end));
+}
+
+export function sourceAt(source: string, position: { start: number; end: number }): string {
+ return source.slice(position.start, position.end);
+}
+
+export function expectBlockStartPosition(ast: LiquidHtmlNode, path: string) {
+ const start = deepGet((path + '.blockStartPosition.start').split('.'), ast) as number;
+ const end = deepGet((path + '.blockStartPosition.end').split('.'), ast) as number;
+ return expect(ast.source.slice(start, end));
+}
+
+export function expectBlockEndPosition(ast: LiquidHtmlNode, path: string) {
+ const start = deepGet((path + '.blockEndPosition.start').split('.'), ast) as number;
+ const end = deepGet((path + '.blockEndPosition.end').split('.'), ast) as number;
+ return expect(ast.source.slice(start, end));
+}
diff --git a/packages/liquid-html-parser/src/document/tokenizer.test.ts b/packages/liquid-html-parser/src/document/tokenizer.test.ts
new file mode 100644
index 000000000..eda773731
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/tokenizer.test.ts
@@ -0,0 +1,545 @@
+import { describe, expect, it } from 'vitest';
+import { tokenize, TokenType } from './tokenizer';
+import type { Token } from './tokenizer';
+
+/** Strip the trailing EndOfInput token for cleaner assertions. */
+function tokens(source: string): Token[] {
+ return tokenize(source).slice(0, -1);
+}
+
+/** Verify structural invariants for any tokenized source. */
+function assertTokenInvariants(source: string) {
+ const result = tokenize(source);
+
+ // Last token is EndOfInput at source.length
+ const last = result[result.length - 1];
+ expect(last.type).toBe(TokenType.EndOfInput);
+ expect(last.start).toBe(source.length);
+ expect(last.end).toBe(source.length);
+
+ // No zero-width tokens (except EndOfInput), no gaps, no overlaps
+ for (let i = 0; i < result.length - 1; i++) {
+ const t = result[i];
+ expect(t.end).toBeGreaterThan(t.start);
+ if (t.type === TokenType.Text) {
+ expect(t.end - t.start).toBeGreaterThan(0);
+ }
+ if (i < result.length - 2) {
+ expect(result[i + 1].start).toBe(t.end);
+ }
+ }
+
+ // Last structural token abuts EndOfInput
+ if (result.length >= 2) {
+ expect(result[result.length - 2].end).toBe(last.start);
+ }
+}
+
+describe('Unit: document-tokenizer', () => {
+ describe('basic Liquid tokens', () => {
+ // "{{ x }}" len=7: 0:{ 1:{ 2: 3:x 4: 5:} 6:}
+ it('tokenizes a Liquid drop {{ x }}', () => {
+ const result = tokens('{{ x }}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidVariableOutputOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 5 },
+ { type: TokenType.LiquidVariableOutputClose, start: 5, end: 7 },
+ ]);
+ assertTokenInvariants('{{ x }}');
+ });
+
+ // "{% if %}" len=8: 0:{ 1:% 2: 3:i 4:f 5: 6:% 7:}
+ it('tokenizes a Liquid tag {% if %}', () => {
+ const result = tokens('{% if %}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidTagOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 6 },
+ { type: TokenType.LiquidTagClose, start: 6, end: 8 },
+ ]);
+ assertTokenInvariants('{% if %}');
+ });
+
+ // "{%- tag -%}" len=11: 0:{ 1:% 2:- 3: 4:t 5:a 6:g 7: 8:- 9:% 10:}
+ it('tokenizes whitespace-trimming Liquid tag {%- tag -%}', () => {
+ const result = tokens('{%- tag -%}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidTagOpen, start: 0, end: 3 },
+ { type: TokenType.Text, start: 3, end: 8 },
+ { type: TokenType.LiquidTagClose, start: 8, end: 11 },
+ ]);
+ assertTokenInvariants('{%- tag -%}');
+ });
+
+ // "{{- x -}}" len=9: 0:{ 1:{ 2:- 3: 4:x 5: 6:- 7:} 8:}
+ it('tokenizes whitespace-trimming Liquid drop {{- x -}}', () => {
+ const result = tokens('{{- x -}}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidVariableOutputOpen, start: 0, end: 3 },
+ { type: TokenType.Text, start: 3, end: 6 },
+ { type: TokenType.LiquidVariableOutputClose, start: 6, end: 9 },
+ ]);
+ assertTokenInvariants('{{- x -}}');
+ });
+
+ // "{% assign x = 'hello' %}" len=24: {%=0..2, text=2..22, %}=22..24
+ it('treats quotes as plain text inside Liquid tag context', () => {
+ const source = "{% assign x = 'hello' %}";
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidTagOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 22 },
+ { type: TokenType.LiquidTagClose, start: 22, end: 24 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // "{{}}" len=4: {{=0..2, }}=2..4
+ it('tokenizes empty Liquid drop {{}}', () => {
+ const result = tokens('{{}}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidVariableOutputOpen, start: 0, end: 2 },
+ { type: TokenType.LiquidVariableOutputClose, start: 2, end: 4 },
+ ]);
+ assertTokenInvariants('{{}}');
+ });
+
+ // "{%%}" len=4: {%=0..2, %}=2..4
+ it('tokenizes empty Liquid tag {%%}', () => {
+ const result = tokens('{%%}');
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidTagOpen, start: 0, end: 2 },
+ { type: TokenType.LiquidTagClose, start: 2, end: 4 },
+ ]);
+ assertTokenInvariants('{%%}');
+ });
+
+ // "{{ x }}{{ y }}" len=14: 0:{ 1:{ 2: 3:x 4: 5:} 6:} 7:{ 8:{ 9: 10:y 11: 12:} 13:}
+ it('tokenizes adjacent drops with no gap', () => {
+ const source = '{{ x }}{{ y }}';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.LiquidVariableOutputOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 5 },
+ { type: TokenType.LiquidVariableOutputClose, start: 5, end: 7 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 7, end: 9 },
+ { type: TokenType.Text, start: 9, end: 12 },
+ { type: TokenType.LiquidVariableOutputClose, start: 12, end: 14 },
+ ]);
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('basic HTML tokens', () => {
+ it('tokenizes
', () => {
+ const result = tokens('
');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 4 },
+ { type: TokenType.HtmlTagClose, start: 4, end: 5 },
+ ]);
+ assertTokenInvariants('
');
+ });
+
+ it('tokenizes
', () => {
+ const result = tokens('
');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlCloseTagOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 5 },
+ { type: TokenType.HtmlTagClose, start: 5, end: 6 },
+ ]);
+ assertTokenInvariants('
');
+ });
+
+ it('tokenizes
', () => {
+ const result = tokens('
');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 3 },
+ { type: TokenType.HtmlSelfClose, start: 3, end: 5 },
+ ]);
+ assertTokenInvariants('
');
+ });
+
+ it('tokenizes
', () => {
+ const result = tokens('
');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 4 },
+ { type: TokenType.HtmlSelfClose, start: 4, end: 6 },
+ ]);
+ assertTokenInvariants('
');
+ });
+
+ // "" len=16: 0:< 1:! 2:- 3:- 4: 5:c ... 11:t 12: 13:- 14:- 15:>
+ it('tokenizes ', () => {
+ const result = tokens('');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlCommentOpen, start: 0, end: 4 },
+ { type: TokenType.Text, start: 4, end: 13 },
+ { type: TokenType.HtmlCommentClose, start: 13, end: 16 },
+ ]);
+ assertTokenInvariants('');
+ });
+
+ it('tokenizes ', () => {
+ const result = tokens('');
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlDoctypeOpen, start: 0, end: 2 },
+ { type: TokenType.Text, start: 2, end: 14 },
+ { type: TokenType.HtmlTagClose, start: 14, end: 15 },
+ ]);
+ assertTokenInvariants('');
+ });
+ });
+
+ describe('HTML tag context (modal)', () => {
+ // "
" len=17: 0:< 1:d 2:i 3:v 4: 5:c 6:l 7:a 8:s 9:s 10:= 11:" 12:f 13:o 14:o 15:" 16:>
+ it('tokenizes
', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.HtmlQuoteOpen, start: 11, end: 12 },
+ { type: TokenType.Text, start: 12, end: 15 },
+ { type: TokenType.HtmlQuoteClose, start: 15, end: 16 },
+ { type: TokenType.HtmlTagClose, start: 16, end: 17 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // "
" len=17: same positions
+ it("tokenizes
", () => {
+ const source = "
";
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.HtmlQuoteOpen, start: 11, end: 12 },
+ { type: TokenType.Text, start: 12, end: 15 },
+ { type: TokenType.HtmlQuoteClose, start: 15, end: 16 },
+ { type: TokenType.HtmlTagClose, start: 16, end: 17 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // "
" len=15: 0:< 1:d...9:s 10:= 11:f 12:o 13:o 14:>
+ it('tokenizes unquoted attribute
', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.Text, start: 11, end: 14 },
+ { type: TokenType.HtmlTagClose, start: 14, end: 15 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ it('tokenizes boolean attribute
![]()
', () => {
+ const source = '
![]()
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 13 },
+ { type: TokenType.HtmlTagClose, start: 13, end: 14 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // '
' len=22
+ // 0:< 1:d 2:i 3:v 4: 5:i 6:d 7:= 8:" 9:a 10:" 11: 12:c 13:l 14:a 15:s 16:s 17:= 18:" 19:b 20:" 21:>
+ it('tokenizes multiple attributes
', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 7 },
+ { type: TokenType.HtmlEquals, start: 7, end: 8 },
+ { type: TokenType.HtmlQuoteOpen, start: 8, end: 9 },
+ { type: TokenType.Text, start: 9, end: 10 },
+ { type: TokenType.HtmlQuoteClose, start: 10, end: 11 },
+ { type: TokenType.Text, start: 11, end: 17 },
+ { type: TokenType.HtmlEquals, start: 17, end: 18 },
+ { type: TokenType.HtmlQuoteOpen, start: 18, end: 19 },
+ { type: TokenType.Text, start: 19, end: 20 },
+ { type: TokenType.HtmlQuoteClose, start: 20, end: 21 },
+ { type: TokenType.HtmlTagClose, start: 21, end: 22 },
+ ]);
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('Liquid inside HTML attributes', () => {
+ // '
' len=21
+ // 0:< 1:d 2:i 3:v 4: 5:c 6:l 7:a 8:s 9:s 10:= 11:" 12:{ 13:{ 14: 15:x 16: 17:} 18:} 19:" 20:>
+ it('tokenizes Liquid drop inside quoted attribute value', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.HtmlQuoteOpen, start: 11, end: 12 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 12, end: 14 },
+ { type: TokenType.Text, start: 14, end: 17 },
+ { type: TokenType.LiquidVariableOutputClose, start: 17, end: 19 },
+ { type: TokenType.HtmlQuoteClose, start: 19, end: 20 },
+ { type: TokenType.HtmlTagClose, start: 20, end: 21 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // '
' len=25
+ // 0:< 1:d 2:i 3:v 4: 5:c 6:l 7:a 8:s 9:s 10:= 11:" 12:a 13: 14:{ 15:{ 16: 17:x 18: 19:} 20:} 21: 22:b 23:" 24:>
+ it('tokenizes text before and after Liquid inside quoted value', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.HtmlQuoteOpen, start: 11, end: 12 },
+ { type: TokenType.Text, start: 12, end: 14 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 14, end: 16 },
+ { type: TokenType.Text, start: 16, end: 19 },
+ { type: TokenType.LiquidVariableOutputClose, start: 19, end: 21 },
+ { type: TokenType.Text, start: 21, end: 23 },
+ { type: TokenType.HtmlQuoteClose, start: 23, end: 24 },
+ { type: TokenType.HtmlTagClose, start: 24, end: 25 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // '
' len=36
+ // 0:< 1:d 2:i 3:v 4: 5:{ 6:% 7: 8:i 9:f 10: 11:c 12: 13:% 14:}
+ // 15:c 16:l 17:a 18:s 19:s 20:= 21:" 22:a 23:" 24:{ 25:% 26: 27:e 28:n 29:d 30:i 31:f 32: 33:% 34:} 35:>
+ it('tokenizes Liquid tag in HTML tag attribute area', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 5 },
+ { type: TokenType.LiquidTagOpen, start: 5, end: 7 },
+ { type: TokenType.Text, start: 7, end: 13 },
+ { type: TokenType.LiquidTagClose, start: 13, end: 15 },
+ { type: TokenType.Text, start: 15, end: 20 },
+ { type: TokenType.HtmlEquals, start: 20, end: 21 },
+ { type: TokenType.HtmlQuoteOpen, start: 21, end: 22 },
+ { type: TokenType.Text, start: 22, end: 23 },
+ { type: TokenType.HtmlQuoteClose, start: 23, end: 24 },
+ { type: TokenType.LiquidTagOpen, start: 24, end: 26 },
+ { type: TokenType.Text, start: 26, end: 33 },
+ { type: TokenType.LiquidTagClose, start: 33, end: 35 },
+ { type: TokenType.HtmlTagClose, start: 35, end: 36 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // '
' len=36
+ // 0:< 1:d 2:i 3:v 4: 5:c 6:l 7:a 8:s 9:s 10:= 11:"
+ // 12:{ 13:% 14: 15:i 16:f 17: 18:c 19: 20:% 21:}
+ // 22:a
+ // 23:{ 24:% 25: 26:e 27:n 28:d 29:i 30:f 31: 32:% 33:}
+ // 34:" 35:>
+ it('tokenizes Liquid tag inside quoted attribute value', () => {
+ const source = '
';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.Text, start: 1, end: 10 },
+ { type: TokenType.HtmlEquals, start: 10, end: 11 },
+ { type: TokenType.HtmlQuoteOpen, start: 11, end: 12 },
+ { type: TokenType.LiquidTagOpen, start: 12, end: 14 },
+ { type: TokenType.Text, start: 14, end: 20 },
+ { type: TokenType.LiquidTagClose, start: 20, end: 22 },
+ { type: TokenType.Text, start: 22, end: 23 },
+ { type: TokenType.LiquidTagOpen, start: 23, end: 25 },
+ { type: TokenType.Text, start: 25, end: 32 },
+ { type: TokenType.LiquidTagClose, start: 32, end: 34 },
+ { type: TokenType.HtmlQuoteClose, start: 34, end: 35 },
+ { type: TokenType.HtmlTagClose, start: 35, end: 36 },
+ ]);
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('compound names', () => {
+ // "<{{ type }}>" len=12: 0:< 1:{ 2:{ 3: 4:t 5:y 6:p 7:e 8: 9:} 10:} 11:>
+ it('tokenizes <{{ type }}>', () => {
+ const source = '<{{ type }}>';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 1, end: 3 },
+ { type: TokenType.Text, start: 3, end: 9 },
+ { type: TokenType.LiquidVariableOutputClose, start: 9, end: 11 },
+ { type: TokenType.HtmlTagClose, start: 11, end: 12 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ // "<{{ type }}--header>" len=20
+ // 0:< 1:{ 2:{ 3: 4:t 5:y 6:p 7:e 8: 9:} 10:} 11:- 12:- 13:h ... 18:r 19:>
+ it('tokenizes <{{ type }}--header>', () => {
+ const source = '<{{ type }}--header>';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.HtmlTagOpen, start: 0, end: 1 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 1, end: 3 },
+ { type: TokenType.Text, start: 3, end: 9 },
+ { type: TokenType.LiquidVariableOutputClose, start: 9, end: 11 },
+ { type: TokenType.Text, start: 11, end: 19 },
+ { type: TokenType.HtmlTagClose, start: 19, end: 20 },
+ ]);
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('YAML frontmatter', () => {
+ // "---\ntitle: foo\n---\ncontent" len=26
+ // 0:- 1:- 2:- 3:\n 4:t...13:o 14:\n 15:- 16:- 17:- 18:\n 19:c...25:t
+ // Frontmatter: ---\n...---\n = 0..19 (includes trailing \n)
+ it('tokenizes frontmatter at position 0', () => {
+ const source = '---\ntitle: foo\n---\ncontent';
+ const result = tokens(source);
+ expect(result[0].type).toBe(TokenType.YamlFrontmatter);
+ expect(result[0].start).toBe(0);
+ expect(result[0].end).toBe(19);
+ expect(result[1]).toMatchObject({ type: TokenType.Text, start: 19 });
+ assertTokenInvariants(source);
+ });
+
+ it('does not tokenize --- when not at position 0', () => {
+ const source = 'text\n---\nstuff\n---';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 18 }]);
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('tokenizes empty document as just EndOfInput', () => {
+ const result = tokenize('');
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ type: TokenType.EndOfInput,
+ start: 0,
+ end: 0,
+ });
+ assertTokenInvariants('');
+ });
+
+ it('tokenizes whitespace-only as Text + EndOfInput', () => {
+ const result = tokens(' ');
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 3 }]);
+ assertTokenInvariants(' ');
+ });
+
+ it('treats < not followed by letter or { as text', () => {
+ const source = '1 < 2';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 5 }]);
+ assertTokenInvariants(source);
+ });
+
+ it('treats lone { as text', () => {
+ const source = 'a { b }';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 7 }]);
+ assertTokenInvariants(source);
+ });
+
+ // "hello {{ x }} world" len=19
+ // 0:h...4:o 5: 6:{ 7:{ 8: 9:x 10: 11:} 12:} 13: 14:w...18:d
+ it('tokenizes text around a Liquid drop', () => {
+ const source = 'hello {{ x }} world';
+ const result = tokens(source);
+ expect(result).toMatchObject([
+ { type: TokenType.Text, start: 0, end: 6 },
+ { type: TokenType.LiquidVariableOutputOpen, start: 6, end: 8 },
+ { type: TokenType.Text, start: 8, end: 11 },
+ { type: TokenType.LiquidVariableOutputClose, start: 11, end: 13 },
+ { type: TokenType.Text, start: 13, end: 19 },
+ ]);
+ assertTokenInvariants(source);
+ });
+
+ it('treats > outside tag context as text', () => {
+ const source = '>';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 1 }]);
+ assertTokenInvariants(source);
+ });
+
+ it('treats = outside tag context as text', () => {
+ const source = '=';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 1 }]);
+ assertTokenInvariants(source);
+ });
+
+ it('treats " outside tag context as text', () => {
+ const source = '"';
+ const result = tokens(source);
+ expect(result).toMatchObject([{ type: TokenType.Text, start: 0, end: 1 }]);
+ assertTokenInvariants(source);
+ });
+
+ it('treats < followed by { as HtmlTagOpen (compound tags)', () => {
+ const source = '<{';
+ const result = tokens(source);
+ expect(result[0]).toMatchObject({ type: TokenType.HtmlTagOpen, start: 0, end: 1 });
+ assertTokenInvariants(source);
+ });
+ });
+
+ describe('structural invariants', () => {
+ const cases = [
+ '{{ x }}',
+ '{% if %}',
+ '{%- tag -%}',
+ '{{- x -}}',
+ "{% assign x = 'hello' %}",
+ '{{}}',
+ '{%%}',
+ '{{ x }}{{ y }}',
+ '
',
+ '
',
+ '
',
+ '
',
+ '',
+ '',
+ '
',
+ "
",
+ '
',
+ '
![]()
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '<{{ type }}>',
+ '<{{ type }}--header>',
+ '---\ntitle: foo\n---\ncontent',
+ 'text\n---\nstuff\n---',
+ '',
+ ' ',
+ '1 < 2',
+ 'a { b }',
+ 'hello {{ x }} world',
+ '>',
+ '=',
+ '"',
+ ];
+
+ for (const source of cases) {
+ it(`invariants hold for: ${JSON.stringify(source).slice(0, 60)}`, () => {
+ assertTokenInvariants(source);
+ });
+ }
+ });
+});
diff --git a/packages/liquid-html-parser/src/document/tokenizer.ts b/packages/liquid-html-parser/src/document/tokenizer.ts
new file mode 100644
index 000000000..dcef56ec4
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/tokenizer.ts
@@ -0,0 +1,327 @@
+import { assertNever } from '../utils';
+
+export enum TokenType {
+ Text = 'Text',
+ LiquidTagOpen = 'LiquidTagOpen',
+ LiquidTagClose = 'LiquidTagClose',
+ LiquidVariableOutputOpen = 'LiquidVariableOutputOpen',
+ LiquidVariableOutputClose = 'LiquidVariableOutputClose',
+ HtmlTagOpen = 'HtmlTagOpen',
+ HtmlCloseTagOpen = 'HtmlCloseTagOpen',
+ HtmlTagClose = 'HtmlTagClose',
+ HtmlSelfClose = 'HtmlSelfClose',
+ HtmlCommentOpen = 'HtmlCommentOpen',
+ HtmlCommentClose = 'HtmlCommentClose',
+ HtmlDoctypeOpen = 'HtmlDoctypeOpen',
+ HtmlEquals = 'HtmlEquals',
+ HtmlQuoteOpen = 'HtmlQuoteOpen',
+ HtmlQuoteClose = 'HtmlQuoteClose',
+ YamlFrontmatter = 'YamlFrontmatter',
+ EndOfInput = 'EndOfInput',
+}
+
+export interface Token {
+ type: TokenType;
+ start: number;
+ end: number;
+}
+
+export interface TokenizeOptions {
+ /**
+ * Skip the document-start YAML frontmatter check. YAML frontmatter is only
+ * valid at offset 0 of a document; when re-tokenizing a mid-document suffix
+ * (see `ParserBase.resliceTokensFrom`) the leading `---` must NOT be treated
+ * as frontmatter.
+ */
+ skipFrontmatter?: boolean;
+
+ /**
+ * Begin tokenizing inside an HTML quoted attribute value, closing on the
+ * given quote character (`"` or `'`). When a `{% raw %}` straddles the
+ * end-tag boundary *inside* a quoted attribute, the suffix re-tokenize (see
+ * `ParserBase.resliceTokensFrom`) must resume in `QuotedValue` mode so the
+ * attribute's closing quote and `>` are emitted as `HtmlQuoteClose`/
+ * `HtmlTagClose` rather than `Text`; a fresh document-start tokenize would
+ * lose that context and make attribute parsing throw.
+ */
+ insideQuotedAttribute?: string;
+
+ /**
+ * Begin tokenizing inside an open HTML tag (the attribute list of `
`
+ * emits as `HtmlTagClose` (only `HtmlTag` mode turns `>` into a tag close;
+ * `Default` mode emits it as `Text`). Without this the attribute list never
+ * breaks on the real `>`, consumes a later element's close, and
+ * `parseHtmlElement` throws at EOF with the element still open. Ignored when
+ * `insideQuotedAttribute` is set (that path already nests `HtmlTag` beneath
+ * `QuotedValue`).
+ */
+ insideHtmlTag?: boolean;
+}
+
+export function tokenize(source: string, options: TokenizeOptions = {}): Token[] {
+ const tokens: Token[] = [];
+ const modeStack: Mode[] = [];
+ let mode = Mode.Default as Mode;
+ let pos = 0;
+ let textStart = -1;
+ let quoteChar = '';
+
+ // Resume inside a quoted attribute value when reslicing a suffix that begins
+ // mid-attribute (e.g. `...{% endraw %}">` straddled by a stray `{{`). The
+ // enclosing HtmlTag mode is kept beneath QuotedValue so popping on the
+ // closing quote correctly emits the attribute close and trailing `>`.
+ if (options.insideQuotedAttribute) {
+ quoteChar = options.insideQuotedAttribute;
+ modeStack.push(Mode.HtmlTag);
+ mode = Mode.QuotedValue;
+ } else if (options.insideHtmlTag) {
+ // Resume inside the attribute list of an open tag. The empty mode stack
+ // means the closing `>` pops back to `Default`, so the rest of the suffix
+ // (`>ok
`) tokenizes as normal document content.
+ mode = Mode.HtmlTag;
+ }
+
+ function ch(offset: number): string {
+ const i = pos + offset;
+ return i < source.length ? source[i] : '';
+ }
+
+ function match(s: string): boolean {
+ return source.startsWith(s, pos);
+ }
+
+ function flushText() {
+ if (textStart !== -1) {
+ tokens.push({ type: TokenType.Text, start: textStart, end: pos });
+ textStart = -1;
+ }
+ }
+
+ function emit(type: TokenType, length: number) {
+ flushText();
+ tokens.push({ type, start: pos, end: pos + length });
+ pos += length;
+ }
+
+ function pushMode(next: Mode) {
+ modeStack.push(mode);
+ mode = next;
+ }
+
+ function popMode() {
+ mode = modeStack.length > 0 ? modeStack.pop()! : Mode.Default;
+ }
+
+ function startText() {
+ if (textStart === -1) textStart = pos;
+ }
+
+ // YAML frontmatter: only at position 0
+ if (!options.skipFrontmatter && (match('---\n') || match('---\r\n'))) {
+ const searchStart = source.indexOf('\n', 0) + 1;
+ const closeIdx = source.indexOf('\n---', searchStart);
+ if (closeIdx !== -1) {
+ let end = closeIdx + 4; // after \n---
+ // Include trailing newline/CRLF
+ if (end < source.length && source[end] === '\r') end++;
+ if (end < source.length && source[end] === '\n') end++;
+ tokens.push({ type: TokenType.YamlFrontmatter, start: 0, end });
+ pos = end;
+ }
+ }
+
+ // Liquid open check — reused in Default, HtmlTag, and QuotedValue modes
+ function scanLiquidOpen(): boolean {
+ if (match('{{-')) {
+ emit(TokenType.LiquidVariableOutputOpen, 3);
+ pushMode(Mode.LiquidVariableOutput);
+ return true;
+ }
+ if (match('{{')) {
+ emit(TokenType.LiquidVariableOutputOpen, 2);
+ pushMode(Mode.LiquidVariableOutput);
+ return true;
+ }
+ if (match('{%-')) {
+ emit(TokenType.LiquidTagOpen, 3);
+ pushMode(Mode.LiquidTag);
+ return true;
+ }
+ if (match('{%')) {
+ emit(TokenType.LiquidTagOpen, 2);
+ pushMode(Mode.LiquidTag);
+ return true;
+ }
+ return false;
+ }
+
+ while (pos < source.length) {
+ switch (mode) {
+ case Mode.LiquidTag: {
+ if (match('-%}')) {
+ emit(TokenType.LiquidTagClose, 3);
+ popMode();
+ } else if (match('%}')) {
+ emit(TokenType.LiquidTagClose, 2);
+ popMode();
+ } else {
+ startText();
+ pos++;
+ }
+ break;
+ }
+
+ case Mode.LiquidVariableOutput: {
+ if (match('-}}')) {
+ emit(TokenType.LiquidVariableOutputClose, 3);
+ popMode();
+ } else if (match('}}')) {
+ emit(TokenType.LiquidVariableOutputClose, 2);
+ popMode();
+ } else {
+ startText();
+ pos++;
+ }
+ break;
+ }
+
+ case Mode.Default: {
+ if (scanLiquidOpen()) continue;
+
+ if (match('')) {
+ emit(TokenType.HtmlCommentClose, 3);
+ continue;
+ }
+
+ if (match('')) {
+ emit(TokenType.HtmlSelfClose, 2);
+ popMode();
+ continue;
+ }
+
+ if (ch(0) === '>') {
+ emit(TokenType.HtmlTagClose, 1);
+ popMode();
+ continue;
+ }
+
+ if (ch(0) === '=') {
+ emit(TokenType.HtmlEquals, 1);
+ continue;
+ }
+
+ // Accept straight quotes and curly (smart) quotes as attribute-value
+ // openers. Curly quotes get normalized to straight quotes downstream;
+ // recognizing them here (HTML scope only — Liquid tokenization is
+ // untouched) stops the value from splitting at interior spaces.
+ if (
+ ch(0) === '"' ||
+ ch(0) === "'" ||
+ ch(0) === '“' ||
+ ch(0) === '”' ||
+ ch(0) === '‘' ||
+ ch(0) === '’'
+ ) {
+ quoteChar = ch(0);
+ emit(TokenType.HtmlQuoteOpen, 1);
+ pushMode(Mode.QuotedValue);
+ continue;
+ }
+
+ startText();
+ pos++;
+ break;
+ }
+
+ case Mode.QuotedValue: {
+ if (scanLiquidOpen()) continue;
+
+ // Curly quotes are directional, so a value opened with a left curly
+ // quote closes on its right partner (and vice-versa); straight quotes
+ // close on themselves.
+ if (ch(0) === closingQuoteFor(quoteChar) || ch(0) === quoteChar) {
+ emit(TokenType.HtmlQuoteClose, 1);
+ popMode();
+ continue;
+ }
+
+ startText();
+ pos++;
+ break;
+ }
+
+ default:
+ assertNever(mode);
+ }
+ }
+
+ flushText();
+ tokens.push({ type: TokenType.EndOfInput, start: source.length, end: source.length });
+ return tokens;
+}
+
+enum Mode {
+ Default = 'Default',
+ HtmlTag = 'HtmlTag',
+ QuotedValue = 'QuotedValue',
+ LiquidTag = 'LiquidTag',
+ LiquidVariableOutput = 'LiquidVariableOutput',
+}
+
+// Curly (smart) quotes come in directional pairs: a value opened with a left
+// curly quote closes on its right partner and vice-versa. Straight quotes are
+// their own partner, so they close on an identical character.
+function closingQuoteFor(open: string): string {
+ switch (open) {
+ case '“':
+ return '”';
+ case '”':
+ return '“';
+ case '‘':
+ return '’';
+ case '’':
+ return '‘';
+ default:
+ return open;
+ }
+}
diff --git a/packages/liquid-html-parser/src/document/tolerant-parser.ts b/packages/liquid-html-parser/src/document/tolerant-parser.ts
new file mode 100644
index 000000000..f942cd61f
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/tolerant-parser.ts
@@ -0,0 +1,111 @@
+import { DocumentParser } from './parser';
+import { TokenType } from './tokenizer';
+import { NodeTypes } from '../types';
+import { LiquidHTMLASTParsingError } from '../errors';
+import type { LiquidErrorNode, LiquidHtmlNode } from '../ast';
+
+/*
+ * Token types that open a new top-level construct. Panic-mode recovery
+ * resynchronizes onto one of these so the next `super.parseNode()` call
+ * resumes on a real node boundary rather than mid-construct. EndOfInput is a
+ * member so recovery always has a boundary to stop on.
+ */
+export const RESYNC_TOKENS: ReadonlySet
= new Set([
+ TokenType.LiquidTagOpen,
+ TokenType.LiquidVariableOutputOpen,
+ TokenType.HtmlTagOpen,
+ TokenType.HtmlCloseTagOpen,
+ TokenType.HtmlCommentOpen,
+ TokenType.HtmlDoctypeOpen,
+ TokenType.EndOfInput,
+]);
+
+/** Whether a token type opens a construct we can safely resume parsing on. */
+export function isResyncToken(type: TokenType): boolean {
+ return RESYNC_TOKENS.has(type);
+}
+
+/*
+ * The readable name of a token type. TokenType is a string enum, so its value
+ * is already the name; this indirection keeps the call sites self-documenting.
+ */
+export function tokenTypeName(type: TokenType): string {
+ return type;
+}
+
+/*
+ * Builds a LiquidErrorNode leaf covering a skipped region. Kept local to the
+ * tolerant path — deliberately not in the frozen factories.ts — so the
+ * default parse has no way to construct it.
+ */
+export function makeLiquidErrorNode(
+ start: number,
+ end: number,
+ source: string,
+ message: string,
+ found?: string,
+): LiquidErrorNode {
+ return {
+ type: NodeTypes.LiquidErrorNode,
+ position: { start, end },
+ source,
+ message,
+ found,
+ };
+}
+
+/*
+ * Opt-in tolerant parser. It behaves exactly like DocumentParser except that
+ * a structural parse failure — which the default parser throws on, aborting the
+ * whole parse — is caught here and turned into a LiquidErrorNode so parsing can
+ * continue. The strict/default DocumentParser is a different class reached by a
+ * different entry point and is left byte-identical.
+ */
+export class TolerantDocumentParser extends DocumentParser {
+ /**
+ * Marks this parse as tolerant so the markup-construction sites enable the
+ * markup parser's tolerant recovery axis. Disjoint from lax.
+ */
+ isTolerant(): boolean {
+ return true;
+ }
+
+ /*
+ * Wraps the polymorphic node parse. On a LiquidHTMLASTParsingError it emits a
+ * LiquidErrorNode covering the skipped region and resynchronizes onto the next
+ * construct-open boundary, so parsing continues and one document can surface
+ * several errors interleaved with the constructs it did recover. A forced
+ * >=1-token advance before the resync scan makes every recovery strictly
+ * advance the cursor, which guarantees the parseDocument loop terminates.
+ * Foreign (non-parse) errors are rethrown untouched.
+ */
+ parseNode(): LiquidHtmlNode {
+ const startTok = this.peek();
+ const startPos = this.getPosition();
+ try {
+ return super.parseNode();
+ } catch (e) {
+ if (!(e instanceof LiquidHTMLASTParsingError)) throw e;
+ const source = this.getSource();
+ const found = tokenTypeName(this.peek().type);
+ /*
+ * Guarantee at least one token of progress before scanning. A failed
+ * parse can throw without having advanced the cursor (consume throws
+ * before its own increment), and if the offending token is itself a
+ * resync token the scan below would match it immediately and never move,
+ * re-trapping parseDocument in an infinite loop. Forcing one advance
+ * breaks that stall.
+ */
+ if (this.getPosition() <= startPos) this.advance();
+ /*
+ * Skip to the next construct-open boundary so the following
+ * super.parseNode() resumes on a real node start rather than mid-
+ * construct. isResyncToken includes EndOfInput, so this also stops
+ * cleanly at the end of the source.
+ */
+ while (!this.isAtEnd() && !isResyncToken(this.peek().type)) this.advance();
+ const end = this.peek().start;
+ return makeLiquidErrorNode(startTok.start, end, source, e.message, found);
+ }
+ }
+}
diff --git a/packages/liquid-html-parser/src/document/tree-builder.test.ts b/packages/liquid-html-parser/src/document/tree-builder.test.ts
new file mode 100644
index 000000000..9193bfd68
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/tree-builder.test.ts
@@ -0,0 +1,209 @@
+import { describe, it, expect } from 'vitest';
+import { NodeTypes } from '../types';
+import type { TextNode, LiquidVariableOutput, LiquidHtmlNode, HtmlElement } from '../ast';
+import { toLiquidHtmlAST } from '../ast';
+import { makeTextNode, makeLiquidVariableOutput } from './factories';
+import { tokenize, TokenType } from './tokenizer';
+import { mergeAdjacentTextNodes, compoundNamesMatch } from './tree-builder';
+
+const OFFSET = 5;
+const PAD = 'x'.repeat(OFFSET);
+
+function makeDropInSource(
+ dropText: string,
+ offset: number,
+ fullSource: string,
+): LiquidVariableOutput {
+ const tokens = tokenize(dropText);
+ const open = tokens.find((t) => t.type === TokenType.LiquidVariableOutputOpen)!;
+ const close = tokens.find((t) => t.type === TokenType.LiquidVariableOutputClose)!;
+ const markup = dropText.slice(open.end, close.start).trim();
+ const adjustedOpen = { ...open, start: open.start + offset, end: open.end + offset };
+ const adjustedClose = { ...close, start: close.start + offset, end: close.end + offset };
+ return makeLiquidVariableOutput(adjustedOpen, adjustedClose, markup, fullSource);
+}
+
+/** Parse `<{%tag%}>x{%tag%}>` and return the LiquidTag node from the open tag's compound name. */
+function makeLiquidTagInName(tagSource: string): LiquidHtmlNode {
+ const ast = toLiquidHtmlAST(`<${tagSource}>x${tagSource}>`);
+ const el = ast.children[0] as HtmlElement;
+ return el.name[0];
+}
+
+describe('Unit: tree-builder', () => {
+ describe('mergeAdjacentTextNodes', () => {
+ it('returns empty array for empty input', () => {
+ const source = PAD;
+ const result = mergeAdjacentTextNodes([], source);
+ expect(result).toEqual([]);
+ });
+
+ it('returns single TextNode unchanged', () => {
+ const source = PAD + 'hello' + PAD;
+ const node = makeTextNode('hello', OFFSET, OFFSET + 5, source);
+ const result = mergeAdjacentTextNodes([node], source);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe(node);
+ });
+
+ it('merges two adjacent TextNodes', () => {
+ const source = PAD + 'hello world' + PAD;
+ const a = makeTextNode('hello ', OFFSET, OFFSET + 6, source);
+ const b = makeTextNode('world', OFFSET + 6, OFFSET + 11, source);
+ const result = mergeAdjacentTextNodes([a, b], source);
+ expect(result).toHaveLength(1);
+ expect(result[0].type).toBe(NodeTypes.TextNode);
+ expect((result[0] as TextNode).value).toBe('hello world');
+ expect(result[0].position.start).toBe(OFFSET);
+ expect(result[0].position.end).toBe(OFFSET + 11);
+ expect(source.slice(result[0].position.start, result[0].position.end)).toBe('hello world');
+ });
+
+ it('merges three adjacent TextNodes', () => {
+ const source = PAD + 'abc' + PAD;
+ const a = makeTextNode('a', OFFSET, OFFSET + 1, source);
+ const b = makeTextNode('b', OFFSET + 1, OFFSET + 2, source);
+ const c = makeTextNode('c', OFFSET + 2, OFFSET + 3, source);
+ const result = mergeAdjacentTextNodes([a, b, c], source);
+ expect(result).toHaveLength(1);
+ expect((result[0] as TextNode).value).toBe('abc');
+ expect(result[0].position).toEqual({ start: OFFSET, end: OFFSET + 3 });
+ expect(source.slice(result[0].position.start, result[0].position.end)).toBe('abc');
+ });
+
+ it('preserves non-TextNode between TextNodes (no merging across boundary)', () => {
+ const source = PAD + 'hello{{ x }}world' + PAD;
+ const text1 = makeTextNode('hello', OFFSET, OFFSET + 5, source);
+ const drop = makeDropInSource('{{ x }}', OFFSET + 5, source);
+ const text2 = makeTextNode('world', OFFSET + 12, OFFSET + 17, source);
+ const result = mergeAdjacentTextNodes([text1, drop, text2], source);
+ expect(result).toHaveLength(3);
+ expect(result[0]).toBe(text1);
+ expect(result[1]).toBe(drop);
+ expect(result[2]).toBe(text2);
+ });
+
+ it('merges only adjacent TextNodes in mixed array', () => {
+ const source = PAD + 'ab{{ x }}cd' + PAD;
+ const a = makeTextNode('a', OFFSET, OFFSET + 1, source);
+ const b = makeTextNode('b', OFFSET + 1, OFFSET + 2, source);
+ const drop = makeDropInSource('{{ x }}', OFFSET + 2, source);
+ const c = makeTextNode('c', OFFSET + 9, OFFSET + 10, source);
+ const d = makeTextNode('d', OFFSET + 10, OFFSET + 11, source);
+ const result = mergeAdjacentTextNodes([a, b, drop, c, d], source);
+ expect(result).toHaveLength(3);
+ expect(result[0].type).toBe(NodeTypes.TextNode);
+ expect((result[0] as TextNode).value).toBe('ab');
+ expect(result[0].position).toEqual({ start: OFFSET, end: OFFSET + 2 });
+ expect(source.slice(result[0].position.start, result[0].position.end)).toBe('ab');
+ expect(result[1]).toBe(drop);
+ expect(result[2].type).toBe(NodeTypes.TextNode);
+ expect((result[2] as TextNode).value).toBe('cd');
+ expect(source.slice(result[2].position.start, result[2].position.end)).toBe('cd');
+ });
+
+ it('returns non-text-only array unchanged', () => {
+ const source = PAD + '{{ a }}{{ b }}' + PAD;
+ const drop1 = makeDropInSource('{{ a }}', OFFSET, source);
+ const drop2 = makeDropInSource('{{ b }}', OFFSET + 7, source);
+ const result = mergeAdjacentTextNodes([drop1, drop2], source);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe(drop1);
+ expect(result[1]).toBe(drop2);
+ });
+
+ it('merges all TextNodes when entire array is TextNodes', () => {
+ const source = PAD + 'abcd' + PAD;
+ const nodes = ['a', 'b', 'c', 'd'].map((ch, i) =>
+ makeTextNode(ch, OFFSET + i, OFFSET + i + 1, source),
+ );
+ const result = mergeAdjacentTextNodes(nodes, source);
+ expect(result).toHaveLength(1);
+ expect((result[0] as TextNode).value).toBe('abcd');
+ expect(source.slice(result[0].position.start, result[0].position.end)).toBe('abcd');
+ });
+ });
+
+ describe('compoundNamesMatch', () => {
+ it('returns true for two empty arrays', () => {
+ expect(compoundNamesMatch([], [])).toBe(true);
+ });
+
+ it('returns true for matching single TextNode names', () => {
+ const source = PAD + 'div' + PAD;
+ const a = [makeTextNode('div', OFFSET, OFFSET + 3, source)];
+ const b = [makeTextNode('div', OFFSET, OFFSET + 3, source)];
+ expect(compoundNamesMatch(a, b)).toBe(true);
+ });
+
+ it('returns false for different TextNode values', () => {
+ const source = PAD + 'divspan' + PAD;
+ const a = [makeTextNode('div', OFFSET, OFFSET + 3, source)];
+ const b = [makeTextNode('span', OFFSET + 3, OFFSET + 7, source)];
+ expect(compoundNamesMatch(a, b)).toBe(false);
+ });
+
+ it('returns false for different array lengths', () => {
+ const source = PAD + 'div' + PAD;
+ const a = [makeTextNode('div', OFFSET, OFFSET + 3, source)];
+ expect(compoundNamesMatch(a, [])).toBe(false);
+ });
+
+ it('returns true for matching compound name with LiquidVariableOutput', () => {
+ const source = PAD + 'header-{{ type }}' + PAD;
+ const textA = makeTextNode('header-', OFFSET, OFFSET + 7, source);
+ const dropA = makeDropInSource('{{ type }}', OFFSET + 7, source);
+ const textB = makeTextNode('header-', OFFSET, OFFSET + 7, source);
+ const dropB = makeDropInSource('{{ type }}', OFFSET + 7, source);
+ expect(compoundNamesMatch([textA, dropA], [textB, dropB])).toBe(true);
+ });
+
+ it('returns false when LiquidVariableOutput markup differs', () => {
+ const sourceA = PAD + '{{ type }}' + PAD;
+ const sourceB = PAD + '{{ kind }}' + PAD;
+ const dropA = makeDropInSource('{{ type }}', OFFSET, sourceA);
+ const dropB = makeDropInSource('{{ kind }}', OFFSET, sourceB);
+ expect(compoundNamesMatch([dropA], [dropB])).toBe(false);
+ });
+
+ it('returns false when node types differ at same index', () => {
+ const sourceText = PAD + 'div' + PAD;
+ const sourceDrop = PAD + '{{ x }}' + PAD;
+ const text = makeTextNode('div', OFFSET, OFFSET + 3, sourceText);
+ const drop = makeDropInSource('{{ x }}', OFFSET, sourceDrop);
+ expect(compoundNamesMatch([text], [drop])).toBe(false);
+ expect(compoundNamesMatch([drop], [text])).toBe(false);
+ });
+
+ it('matches by markup string regardless of position', () => {
+ const sourceA = PAD + '{{ type }}' + PAD;
+ const sourceB = 'xx' + '{{ type }}' + PAD;
+ const dropA = makeDropInSource('{{ type }}', OFFSET, sourceA);
+ const dropB = makeDropInSource('{{ type }}', 2, sourceB);
+ expect(compoundNamesMatch([dropA], [dropB])).toBe(true);
+ });
+
+ it('returns true for matching LiquidTag nodes (same source text)', () => {
+ const tagA = makeLiquidTagInName('{% if true %}x{% endif %}');
+ const tagB = makeLiquidTagInName('{% if true %}x{% endif %}');
+ expect(compoundNamesMatch([tagA], [tagB])).toBe(true);
+ });
+
+ it('returns true for wholly-LiquidTag names even when branch content differs (Ruby parity)', () => {
+ // The rendered tag name is decided at runtime by the Liquid block, so it
+ // cannot be matched statically. Ruby's parser does not enforce open/close
+ // matching for Liquid-interpolated names; the editor trusts the pair.
+ const tagA = makeLiquidTagInName('{% if true %}x{% endif %}');
+ const tagB = makeLiquidTagInName('{% if true %}y{% endif %}');
+ expect(compoundNamesMatch([tagA], [tagB])).toBe(true);
+ });
+
+ it('returns false when LiquidTag vs LiquidVariableOutput at same index', () => {
+ const tag = makeLiquidTagInName('{% if true %}x{% endif %}');
+ const sourceDrop = PAD + '{{ x }}' + PAD;
+ const drop = makeDropInSource('{{ x }}', OFFSET, sourceDrop);
+ expect(compoundNamesMatch([tag], [drop])).toBe(false);
+ expect(compoundNamesMatch([drop], [tag])).toBe(false);
+ });
+ });
+});
diff --git a/packages/liquid-html-parser/src/document/tree-builder.ts b/packages/liquid-html-parser/src/document/tree-builder.ts
new file mode 100644
index 000000000..a2ec91789
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/tree-builder.ts
@@ -0,0 +1,210 @@
+import { NodeTypes } from '../types';
+import type { LiquidHtmlNode, TextNode, LiquidVariableOutput } from '../ast';
+import { makeTextNode } from './factories';
+import { assertNever } from '../utils';
+
+export enum ChildFilterMode {
+ /** Syntactic auto-skip: merge adjacent TextNodes, trim whitespace at boundaries, drop empty. */
+ Syntactic,
+ /** Merge adjacent TextNodes, preserve all whitespace content. */
+ Preserve,
+ /** Merge, then strip whitespace-only TextNodes from first/last positions only. */
+ StripEdges,
+}
+
+export function filterChildren(
+ mode: ChildFilterMode,
+ children: LiquidHtmlNode[],
+ source: string,
+): LiquidHtmlNode[] {
+ switch (mode) {
+ case ChildFilterMode.Syntactic:
+ return mergeAdjacentTextNodesTrimmed(children, source);
+ case ChildFilterMode.Preserve:
+ return mergeAdjacentTextNodes(children, source);
+ case ChildFilterMode.StripEdges:
+ return mergeAdjacentTextNodesStripEdges(children, source);
+ default:
+ assertNever(mode);
+ }
+}
+
+function isTextNode(node: LiquidHtmlNode): node is TextNode {
+ return node.type === NodeTypes.TextNode;
+}
+
+function isLiquidVariableOutput(node: LiquidHtmlNode): node is LiquidVariableOutput {
+ return node.type === NodeTypes.LiquidVariableOutput;
+}
+
+export function mergeAdjacentTextNodes(nodes: LiquidHtmlNode[], source: string): LiquidHtmlNode[] {
+ if (nodes.length <= 1) return nodes;
+
+ const result: LiquidHtmlNode[] = [];
+ let runStart = -1;
+
+ for (let i = 0; i <= nodes.length; i++) {
+ const node = nodes[i];
+ const inRun = runStart !== -1;
+ const isText = node !== undefined && isTextNode(node);
+
+ if (isText && !inRun) {
+ runStart = i;
+ continue;
+ }
+
+ if (!isText && inRun) {
+ const runEnd = i - 1;
+ if (runStart === runEnd) {
+ result.push(nodes[runStart]);
+ } else {
+ const first = nodes[runStart] as TextNode;
+ const last = nodes[runEnd] as TextNode;
+ result.push(
+ makeTextNode(
+ source.slice(first.position.start, last.position.end),
+ first.position.start,
+ last.position.end,
+ source,
+ ),
+ );
+ }
+ runStart = -1;
+ }
+
+ if (node !== undefined && !isText) {
+ result.push(node);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Merge adjacent text nodes then trim whitespace from text node boundaries
+ * adjacent to non-text nodes. Used for raw tag bodies and branch children
+ * where surrounding whitespace around Liquid tags should be dropped.
+ */
+export function mergeAdjacentTextNodesTrimmed(
+ nodes: LiquidHtmlNode[],
+ source: string,
+): LiquidHtmlNode[] {
+ const merged = mergeAdjacentTextNodes(nodes, source);
+ if (merged.length === 0) return merged;
+
+ const result: LiquidHtmlNode[] = [];
+ for (let i = 0; i < merged.length; i++) {
+ const node = merged[i];
+ if (!isTextNode(node)) {
+ result.push(node);
+ continue;
+ }
+
+ let value = node.value;
+ let start = node.position.start;
+ let end = node.position.end;
+
+ // Trim leading whitespace if preceded by a non-text node or at start
+ const prev = merged[i - 1];
+ if (!prev || !isTextNode(prev)) {
+ const trimmed = value.replace(/^\s+/, '');
+ const trimLen = value.length - trimmed.length;
+ value = trimmed;
+ start += trimLen;
+ }
+
+ // Trim trailing whitespace if followed by a non-text node or at end
+ const next = merged[i + 1];
+ if (!next || !isTextNode(next)) {
+ const trimmed = value.replace(/\s+$/, '');
+ const trimLen = value.length - trimmed.length;
+ value = trimmed;
+ end -= trimLen;
+ }
+
+ if (value.length > 0) {
+ result.push(makeTextNode(value, start, end, source));
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Merge adjacent text nodes then strip whitespace-only text nodes from the
+ * edges. Used at the document root and tag children where meaningful text
+ * content (including its whitespace) should be preserved, but whitespace-only
+ * edge nodes from template indentation should be dropped.
+ */
+export function mergeAdjacentTextNodesStripEdges(
+ nodes: LiquidHtmlNode[],
+ source: string,
+): LiquidHtmlNode[] {
+ const merged = mergeAdjacentTextNodes(nodes, source);
+ let start = 0;
+ let end = merged.length;
+ while (start < end && isWhitespaceOnlyTextNode(merged[start])) start++;
+ while (end > start && isWhitespaceOnlyTextNode(merged[end - 1])) end--;
+ if (start === 0 && end === merged.length) return merged;
+ return merged.slice(start, end);
+}
+
+function isWhitespaceOnlyTextNode(node: LiquidHtmlNode): boolean {
+ return isTextNode(node) && /^\s+$/.test(node.value);
+}
+
+export function compoundNamesMatch(a: LiquidHtmlNode[], b: LiquidHtmlNode[]): boolean {
+ // When the entire tag name is a Liquid control-flow block (e.g.
+ // `<{% if cond %}sticky-header{% else %}div{% endif %}>`), the resolved
+ // element name is determined at render time and cannot be verified
+ // statically. The open and close blocks may also differ in their branch
+ // bodies (the open tag can glue an attribute into the conditional, e.g.
+ // `sticky-header data-sticky-type="..."`, while the close tag only names
+ // the element), so source-text comparison rejects a valid pair. Ruby's
+ // parser accepts these; trust the pair when both names are wholly a single
+ // LiquidTag/LiquidRawTag segment.
+ if (isWhollyLiquidTagName(a) && isWhollyLiquidTagName(b)) return true;
+
+ if (a.length !== b.length) return false;
+
+ for (let i = 0; i < a.length; i++) {
+ const nodeA = a[i];
+ const nodeB = b[i];
+
+ if (nodeA.type !== nodeB.type) return false;
+
+ if (isTextNode(nodeA) && isTextNode(nodeB)) {
+ if (nodeA.value !== nodeB.value) return false;
+ continue;
+ }
+
+ if (isLiquidVariableOutput(nodeA) && isLiquidVariableOutput(nodeB)) {
+ const markupA = typeof nodeA.markup === 'string' ? nodeA.markup : nodeA.markup.rawSource;
+ const markupB = typeof nodeB.markup === 'string' ? nodeB.markup : nodeB.markup.rawSource;
+ if (markupA !== markupB) return false;
+ continue;
+ }
+
+ // LiquidTag or LiquidRawTag: compare by source text
+ if (nodeA.type === nodeB.type && 'source' in nodeA && 'source' in nodeB) {
+ const srcA = nodeA.source.slice(nodeA.position.start, nodeA.position.end);
+ const srcB = nodeB.source.slice(nodeB.position.start, nodeB.position.end);
+ if (srcA !== srcB) return false;
+ continue;
+ }
+
+ return false;
+ }
+
+ return true;
+}
+
+// A tag name that is entirely a Liquid control-flow block (LiquidTag/LiquidRawTag),
+// so the rendered element name is decided at runtime and cannot be matched
+// statically against its close tag. See `compoundNamesMatch`.
+function isWhollyLiquidTagName(segments: LiquidHtmlNode[]): boolean {
+ return (
+ segments.length === 1 &&
+ (segments[0]!.type === NodeTypes.LiquidTag || segments[0]!.type === NodeTypes.LiquidRawTag)
+ );
+}
diff --git a/packages/liquid-html-parser/src/document/unclosed.test.ts b/packages/liquid-html-parser/src/document/unclosed.test.ts
new file mode 100644
index 000000000..5a62dd90f
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/unclosed.test.ts
@@ -0,0 +1,187 @@
+import { describe, it, expect } from 'vitest';
+import { toLiquidHtmlAST } from '../ast';
+import { deepGet } from '../utils';
+import { expectPath, sourceAt } from './test-helpers';
+
+describe('Unit: unclosed', () => {
+ describe('conditional branches allow unclosed HTML (if/unless/case)', () => {
+ it('should allow unclosed inside if', () => {
+ const source = '{% if cond %}
{% endif %}';
+ const ast = toLiquidHtmlAST(source);
+ expectPath(ast, 'children.0.type').to.eql('LiquidTag');
+ expectPath(ast, 'children.0.children.0.type').to.eql('LiquidBranch');
+ const el = deepGet('children.0.children.0.children.0'.split('.'), ast) as any;
+ expect(el.type).to.eql('HtmlElement');
+ // blockEndPosition slices to empty string (zero-width)
+ expect(sourceAt(source, el.blockEndPosition)).to.eql('');
+ // blockEndPosition is not -1 (>= blockStartPosition.end)
+ expect(el.blockEndPosition.start).to.be.greaterThanOrEqual(el.blockStartPosition.end);
+ });
+
+ it('should allow unclosed
with text child inside if/else', () => {
+ const source = '{% if cond %}
text{% else %}more{% endif %}';
+ const ast = toLiquidHtmlAST(source);
+ const el = deepGet('children.0.children.0.children.0'.split('.'), ast) as any;
+ expect(el.type).to.eql('HtmlElement');
+ expect(el.children).to.have.lengthOf(1);
+ expect(el.children[0].value).to.eql('text');
+ expect(sourceAt(source, el.blockEndPosition)).to.eql('');
+ expect(el.blockEndPosition.start).to.be.greaterThan(el.blockStartPosition.end);
+ });
+
+ it('should allow unclosed
inside unless', () => {
+ const source = '{% unless cond %}
{% endunless %}';
+ const ast = toLiquidHtmlAST(source);
+ const el = deepGet('children.0.children.0.children.0'.split('.'), ast) as any;
+ expect(el.type).to.eql('HtmlElement');
+ expect(sourceAt(source, el.blockEndPosition)).to.eql('');
+ expect(el.blockEndPosition.start).to.be.greaterThanOrEqual(el.blockStartPosition.end);
+ });
+
+ it('should allow unclosed
inside case/when', () => {
+ const source = '{% case x %}{% when y %}