Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/standalone-section-tag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/liquid-html-parser': patch
'@shopify/theme-check-common': patch
---

Parse `section` as a standalone tag. Removes the hybrid block form, whose forward scan for `{% endsection %}` made files with many `section` tags parse in quadratic time.
44 changes: 8 additions & 36 deletions packages/liquid-html-parser/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ src/
tokenizer.ts # Source -> Token[] (modal: HTML tag context with =, ", ')
html.ts # HTML element, void, self-closing, raw node, comment, doctype, dangling marker parsing
liquid-blocks.ts # Block tag body parsing, branched block parsing, finalizeBranch
liquid-hybrid.ts # Hybrid tag parsing (section standalone/block detection)
liquid-lines.ts # {% liquid %} line-based parsing, parseLiquidStatement
liquid-raw.ts # Raw tag body parsing, Liquid-in-range parsing
liquid-tags.ts # Tag dispatch: environment lookup, MarkupParser creation, tolerant fallback
Expand Down Expand Up @@ -110,7 +109,6 @@ document/parser.ts (DocumentParser)
| - node-dispatch.ts: token-type switch -> parse function routing
| - liquid-tags.ts: tag dispatch (environment lookup, MarkupParser creation)
| - liquid-blocks.ts: block body + branched block parsing
| - liquid-hybrid.ts: hybrid tag (section) standalone/block detection
| - liquid-raw.ts: raw tag body extraction + Liquid-in-range parsing
| - liquid-variable-output.ts: {{ }} variable output
| - liquid-lines.ts: {% liquid %} line-based parsing
Expand Down Expand Up @@ -156,7 +154,6 @@ The parser calls these utilities while building the AST.
| `document/tree-builder.ts` | Utility functions for the parser: `filterChildren`, `ChildFilterMode`, `mergeAdjacentTextNodes`, `mergeAdjacentTextNodesTrimmed`, `mergeAdjacentTextNodesStripEdges`, `compoundNamesMatch`. Pure functions, zero parser state. | Tokenizing. Parsing. Dispatch. |
| `document/html.ts` | HTML element parsing (open/close matching, unclosed element handling), void elements, self-closing elements, raw HTML nodes (`<script>`, `<style>`, `<svg>`), comments, doctype, dangling marker close. Attribute parsing. Defines `HtmlParserDelegate` interface. | Liquid tag parsing. Expression parsing. |
| `document/liquid-blocks.ts` | Block tag body parsing (`parseBlockBody`), branched block parsing (`parseBranchedBlockBody`), `finalizeBranch`, `peekTagName`, `isBlockTerminator`, `consumeEndTag`. Defines `BlockParserDelegate` interface. | Tag dispatch. Expression parsing. HTML parsing. |
| `document/liquid-hybrid.ts` | Hybrid tag detection: scans forward in token array for matching end tag. Defines `HybridParserDelegate` interface. | Tag dispatch. Expression parsing. |
| `document/liquid-lines.ts` | `{% liquid %}` line-based parsing: `parseLiquidStatement`, line splitting, block nesting within liquid tags. Defines `LineParserDelegate` interface. | Document tokenization. HTML parsing. |
| `document/liquid-raw.ts` | Raw tag body extraction (scan forward for end tag). `parseLiquidInRange` for tags that parse Liquid inside their body. Defines `RawParserDelegate` interface. | Expression parsing. Tag dispatch. |
| `document/liquid-tags.ts` | Tag dispatch: environment lookup, `MarkupParser` creation from envelope, tolerant-mode try/catch fallback to base case. Defines `TagParserDelegate` interface. | Block body parsing. HTML parsing. |
Expand Down Expand Up @@ -495,7 +492,6 @@ enum TagKind {
Block = "block",
Tag = "tag",
Raw = "raw",
Hybrid = "hybrid",
}

type BranchName = "elsif" | "else" | "when";
Expand All @@ -517,16 +513,10 @@ interface TagDefinitionRaw<M = unknown> {
parse(name: string, markup: MarkupParser, parser: Parser): M;
}

interface TagDefinitionHybrid<M = unknown> {
kind: TagKind.Hybrid;
parse(name: string, markup: MarkupParser, parser: Parser): M;
}

type TagDefinition<M = unknown> =
| TagDefinitionBlock<M>
| TagDefinitionTag<M>
| TagDefinitionRaw<M>
| TagDefinitionHybrid<M>;
| TagDefinitionRaw<M>;
```

Dispatch uses switch + `assertNever`:
Expand All @@ -536,7 +526,6 @@ switch (def.kind) {
case TagKind.Block: return parseBlockTag(...);
case TagKind.Tag: return parseTag(...);
case TagKind.Raw: return parseRawTag(...);
case TagKind.Hybrid: return parseHybridTag(...);
default: return assertNever(def);
}
```
Expand Down Expand Up @@ -611,7 +600,7 @@ Defined in frozen `ast.ts`. Produced when a `</tagName>` close tag does not matc

### D1: TagDefinition as discriminated union on `kind`

Tags define behavior via `kind: TagKind.Block | TagKind.Tag | TagKind.Raw | TagKind.Hybrid` with generic `Markup` parameter. Block tags declare `branches: BranchName[]`. The `kind` discriminant drives exhaustive dispatch (see Section 4, TagDefinition).
Tags define behavior via `kind: TagKind.Block | TagKind.Tag | TagKind.Raw` with generic `Markup` parameter. Block tags declare `branches: BranchName[]`. The `kind` discriminant drives exhaustive dispatch (see Section 4, TagDefinition).

### D2: Internal BinaryExpression with adapter to frozen types

Expand Down Expand Up @@ -655,11 +644,7 @@ See Section 2 for domain table and import rules.

Tokenizer produces flat `[LiquidTagOpen, Text, LiquidTagClose, ...]`. Parser matches pairs by consuming forward. Pairing is the parser's job.

### D12: Section hybrid detection via lookahead

When parser encounters `{% section %}`, it scans forward in the token array for `{% endsection %}`. The array is materialized, so scanning is a cheap index walk.

### D13: HTML parsing in the document domain
### D12: HTML parsing in the document domain

See Section 2 domain table. HTML is parsed in `document/parser.ts`, not a separate domain.

Expand Down Expand Up @@ -768,15 +753,7 @@ liquid tag parse(name, markupParser, parser):

**Inline comments:** Lines starting with `#` are intercepted before environment lookup — `#` is not a valid identifier start, so normal tag name extraction (`content.split(/\s/)[0]`) cannot handle it. The parser checks `content.startsWith('#')` and short-circuits to produce `LiquidTag { name: '#', markup: 'rest of line' }` directly.

### 6.4 Section Hybrid Tag

`section` is the only hybrid tag: standalone (`{% section 'name' %}`) or block (`{% section 'name' %}...{% endsection %}`).

**Detection:** When parser encounters `{% section %}`, it scans forward in the token array for `{% endsection %}`, respecting nesting. If found, parse as block. Otherwise, standalone.

**Markup for both forms:** `SectionMarkup { name: LiquidString, args: LiquidNamedArgument[] }`. Same regardless of form. The difference is whether `children` is populated and `blockEndPosition` exists.

### 6.5 Tolerant Mode vs Completion Mode
### 6.4 Tolerant Mode vs Completion Mode

**Tolerant mode** falls back to base case on markup parse failure but throws on structural errors:

Expand All @@ -792,7 +769,7 @@ Fallback is per-tag. If `{% assign x = bad!markup %}` fails, only the `assign` t

Detailed behavior is specified in `recursive-descent.md`.

### 6.6 `when` Markup: Expression List with Separators
### 6.5 `when` Markup: Expression List with Separators

The `when` tag's markup is `LiquidExpression[]`, NOT a logical expression. `or` and `,` in `{% when val1, val2 or val3 %}` are value separators, producing three `LiquidExpression` entries.

Expand All @@ -815,7 +792,7 @@ Note: `or` is lexed as `MarkupTokenType.Logical`, not `Id`, so `id('or')` cannot

This matches Ruby's implementation. If `or` were treated as logical, `{% when a or b %}` would wrongly produce `LiquidLogicalExpression`. The frozen type `LiquidBranchWhen` has `markup: LiquidExpression[]`.

### 6.7 Entry Points
### 6.6 Entry Points

```
toLiquidHtmlAST(source, options?)
Expand All @@ -830,7 +807,7 @@ toLiquidAST(source, options?)
Parser treats HTML tokens as text -- no HtmlElement nodes produced.
```

### 6.8 Tag Dispatch
### 6.7 Tag Dispatch

```
Parser sees LiquidTagOpen token
Expand Down Expand Up @@ -870,15 +847,10 @@ Parser sees LiquidTagOpen token
| If tagName == 'doc': pass body to liquid-doc/parser
| makeLiquidRawTag(envelope, body, endPos, endWs)
|
+-- TagKind.Hybrid ->
| Scan forward for matching {% endtagname %}
| If found: parse as block
| If not found: parse as standalone
|
+-- default -> assertNever(def)
```

### 6.9 Branch Body Parsing
### 6.8 Branch Body Parsing

```
parseBranchedBlockBody(parentName, def, envelope):
Expand Down
1 change: 0 additions & 1 deletion packages/liquid-html-parser/specs/recursive-descent.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ Standalone tags (no close tag) have `delimiterWhitespaceStart`/`delimiterWhitesp
| **Branching** (delimiters inside blocks) | `elsif`, `else`, `when` | Creates `LiquidBranch` nodes inside parent block. |
| **Raw** (body not parsed) | `raw`, `comment`, `doc`, `javascript`, `schema`, `style`, `stylesheet` | Body is raw string until `{% endtagname %}`. |
| **Standalone** (no close tag) | `echo`, `assign`, `render`, `include`, `increment`, `decrement`, `cycle`, `layout`, `section`, `sections`, `content_for`, `break`, `continue`, `liquid` | Self-contained, no children. |
| **Hybrid** | `section` can be both standalone AND block form (`{% section 'name' %}...{% endsection %}`) | Check for presence of `{% endsection %}`. |

**Position fields on block tags and branches:**

Expand Down
51 changes: 13 additions & 38 deletions packages/liquid-html-parser/src/ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,50 +1105,25 @@ describe('Unit: Stage 2 (AST)', () => {
}
});

it('should parse section hybrid (block form) with endsection', () => {
for (const { toAST, expectPath } of testCases) {
const source = `{% section 'foo' %}content{% endsection %}`;
ast = toAST(source);
expectPath(ast, 'children.0.type').to.eql('LiquidTag');
expectPath(ast, 'children.0.name').to.eql('section');
expectPath(ast, 'children.0.markup.name.value').to.eql('foo');
expectPath(ast, 'children.0.children.0.type').to.eql('TextNode');
expectPath(ast, 'children.0.children.0.value').to.eql('content');

// blockEndPosition should span the {% endsection %} tag exactly
expectPath(ast, 'children.0.blockEndPosition.start').to.eql(
source.indexOf('{% endsection %}'),
it('should treat section as standalone and reject stray endsection tags', () => {
for (const { toAST } of testCases) {
expect(() => toAST(`{% section 'foo' %}content{% endsection %}`)).to.throw(
/without a matching 'section'/,
);
expectPath(ast, 'children.0.blockEndPosition.end').to.eql(source.length);

// section node position.end should match endsection's end
expectPath(ast, 'children.0.position.end').to.eql(source.length);

expectPath(ast, 'children.0.delimiterWhitespaceStart').to.eql('');
expectPath(ast, 'children.0.delimiterWhitespaceEnd').to.eql('');
}
});

it('should capture whitespace trimming on endsection', () => {
for (const { toAST, expectPath } of testCases) {
const source = `{% section 'foo' %}content{%- endsection -%}`;
ast = toAST(source);
expectPath(ast, 'children.0.name').to.eql('section');
expectPath(ast, 'children.0.delimiterWhitespaceStart').to.eql('-');
expectPath(ast, 'children.0.delimiterWhitespaceEnd').to.eql('-');
expectPath(ast, 'children.0.blockEndPosition.start').to.eql(
source.indexOf('{%- endsection -%}'),
expect(() => toAST(`{% endsection %}`)).to.throw(/without a matching 'section'/);
expect(() => toAST(`{% if a %}{% endsection %}{% endif %}`)).to.throw(
/before LiquidTag 'if' was closed/,
);
expectPath(ast, 'children.0.blockEndPosition.end').to.eql(source.length);
expectPath(ast, 'children.0.position.end').to.eql(source.length);
}
});

it('should throw on orphaned endsection', () => {
Comment thread
stephanie-shopify marked this conversation as resolved.
it('should still throw on stray end tags for other registered tags', () => {
for (const { toAST } of testCases) {
expect(() => {
toAST(`{% endsection %}`);
}).to.throw(/without a matching/);
expect(() => toAST(`{% endrender %}`)).to.throw(/without a matching 'render'/);
expect(() => toAST(`{% endsections %}`)).to.throw(/without a matching 'sections'/);
expect(() => toAST(`{% if a %}{% endrender %}{% endif %}`)).to.throw(
/before LiquidTag 'if' was closed/,
);
}
});

Expand Down
5 changes: 3 additions & 2 deletions packages/liquid-html-parser/src/document/liquid-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
LiquidBranchNamed,
AttributeNode,
} from '../ast';
import { isStructuralEndTag } from '../environment';
import type { TagDefinitionBlock, BranchName, Parser } from '../environment';
import { LiquidHTMLASTParsingError } from '../errors';
import { assertNever } from '../utils';
Expand Down Expand Up @@ -126,7 +127,7 @@ export function parseBlockBody(

if (tagName && tagName.startsWith('end')) {
const innerName = tagName.slice(3);
if (parser.blockEnv.tagForName(innerName)) {
if (isStructuralEndTag(innerName, parser.blockEnv.tagForName(innerName))) {
throw new LiquidHTMLASTParsingError(
`Attempting to close LiquidTag '${innerName}' before LiquidTag '${parentName}' was closed`,
parser.getSource(),
Expand Down Expand Up @@ -252,7 +253,7 @@ export function parseBranchedBody(

if (tagName && tagName.startsWith('end')) {
const innerName = tagName.slice(3);
if (parser.blockEnv.tagForName(innerName)) {
if (isStructuralEndTag(innerName, parser.blockEnv.tagForName(innerName))) {
throw new LiquidHTMLASTParsingError(
`Attempting to close LiquidTag '${innerName}' before LiquidTag '${parentName}' was closed`,
parser.getSource(),
Expand Down
Loading
Loading