Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
34dae62
Tokenize plain text runs in one step in liquid-html-parser
PhilippeCollin Oct 8, 2026
f11f802
Test that the tokenizer fast path stops at every token start
PhilippeCollin Oct 8, 2026
5b634d9
Check character codes before regexes in liquid-html-parser tokenizers
PhilippeCollin Oct 8, 2026
320fa1f
Trim text around Liquid tags with trimStart/trimEnd
PhilippeCollin Oct 8, 2026
a73b174
Parse Object.prototype names as ordinary names in liquid-html-parser
PhilippeCollin Oct 8, 2026
1d06444
Type block and section arguments as one BlockArgument
natachalevesque Sep 10, 2026
b8414be
Test parse error locations at the end of input
iaineh Aug 31, 2026
e391d5c
Let each raw tag end its body by its own Liquid rule
shopify-river Oct 8, 2026
81bf98b
Complete block arguments through the BlockArgument type
PhilippeCollin Oct 30, 2026
ca054cf
Expect raw tag syntax errors where the parser now reports them
PhilippeCollin Oct 30, 2026
3b42770
Add a pairDanglingCloseTags option for branch-opened elements
shopify-river Sep 30, 2026
08b22df
Pair a dangling close only with the innermost branch-opened element
shopify-river Sep 30, 2026
b9ab5c7
Test that block and section markup keep the full source
clauderic Sep 25, 2026
64e4ffb
Read script and style bodies as raw text
shopify-river Oct 14, 2026
f5e177e
Give the tokenizer an HTML comment mode
shopify-river Oct 21, 2026
31f6655
Parse the Liquid inside HTML comments
PhilippeCollin Oct 30, 2026
7cb4970
Add changesets for the parser sync
PhilippeCollin Oct 30, 2026
2fd0edf
Test that a trailing reversed doesn't reverse a for loop
PhilippeCollin Nov 3, 2026
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/block-argument-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/liquid-html-parser': minor
'@shopify/theme-language-server-common': patch
---

Type `{% block %}` and `{% section %}` arguments as one `BlockArgument`, whose value is a scalar or a single-level array literal, instead of a `LiquidNamedArgument | BlockArrayArgument` union. Completions narrow on the argument's value.
16 changes: 16 additions & 0 deletions .changeset/html-comment-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@shopify/liquid-html-parser': minor
'@shopify/prettier-plugin-liquid': patch
'@shopify/theme-check-common': patch
'@shopify/theme-language-server-common': patch
---

Parse the Liquid inside HTML comments. Liquid renders it, and its tags can open, branch and close blocks, so `{% if a %}<!-- {% endif %} -->` no longer fails with an unclosed `if`.

- `HtmlComment` has `children` (text and Liquid) and `blockStartPosition`/`blockEndPosition` for its delimiters.
- A comment that crosses a Liquid block boundary is split there, and each part has an empty delimiter range on the split side.
- A comment that never closes ends at the end of the file, as in a browser.
- An end tag in a comment that closes no block is a parse error, as in Liquid.
- The `opaqueHtmlComments` option keeps comment bodies opaque.

Checks now see the Liquid in HTML comments, completions work inside them, and prettier prints split and unclosed comments as written.
5 changes: 5 additions & 0 deletions .changeset/html-comment-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Tokenize HTML comments as a browser does. In a comment only Liquid and the comment's close count, so a `<b`, an unbalanced quote or a nested `<!--` can no longer hide the `-->`. A comment closes on `-->` or `--!>`, and `<!-->` and `<!--->` are empty comments. Outside a comment, `-->` is text. The bodies of Liquid raw tags like `{% comment %}` are opaque to the tokenizer too, so a `<!--` or a quote in one no longer affects the rest of the file.
5 changes: 5 additions & 0 deletions .changeset/linear-text-trim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Trim whitespace around Liquid tags with `trimStart()`/`trimEnd()` instead of regular expressions. The trailing-whitespace regex was quadratic in the length of a whitespace run and was the single most expensive step of parsing a typical theme.
5 changes: 5 additions & 0 deletions .changeset/markup-tokenizer-fast-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Check character codes before running regular expressions or `startsWith` in the Liquid expression tokenizer and when looking for `{{`/`{%`. Tokens and ASTs are unchanged; parsing is 5–7% faster.
5 changes: 5 additions & 0 deletions .changeset/pair-dangling-close-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': minor
---

Add a `pairDanglingCloseTags` option, off by default. Themes often open an element in each branch of a conditional and close it once after it (`{% if a %}<div class="x">{% else %}<div class="y">{% endif %}…</div>`). With the option, such a close tag becomes an `HtmlDanglingMarkerClose` when an earlier `if`, `unless` or `case` in the same scope leaves an element of that name open on every path, instead of a parse error. It pairs only with the innermost such element.
5 changes: 5 additions & 0 deletions .changeset/prototype-names-not-keywords.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Parse names inherited from `Object.prototype` like any other name. `{{ constructor }}` and `{{ toString }}` were parsed as Liquid literals whose value was a JavaScript function, and `{% constructor %}` or `{% __proto__ %}` made the parser throw instead of producing an unknown tag. Recognizing literal keywords with a `switch` is also slightly faster.
12 changes: 12 additions & 0 deletions .changeset/raw-tag-body-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@shopify/liquid-html-parser': patch
---

End each raw tag's body by the rule of the Liquid class that parses it, in templates and in `{% liquid %}`:

- `{% comment %}` nests, skips the bodies of raw tags inside it, and closes on any tag named `endcomment`, so `{% endcomment xyz %}` closes it.
- `{% raw %}`, `{% javascript %}`, `{% schema %}` and `{% stylesheet %}` close at their first end tag, extra markup included, so `{% endraw xyz %}` closes `{% raw %}`.
- `{% doc %}` reads like `{% raw %}` but rejects a nested `{% doc %}`.
- `{% style %}` is a block, so it nests.

Templates that Liquid reports as never closed now fail to parse too, such as a comment whose body Liquid's tokenizer runs past the `{% endcomment %}`.
5 changes: 5 additions & 0 deletions .changeset/script-style-raw-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Read `<script>` and `<style>` bodies as raw text, as a browser does: a `<` in them is JavaScript or CSS, not an HTML tag, and the element closes at its first close tag outside Liquid. So `<script>a<script>b</script>c</script>` now ends the script at the first `</script>` and reports the second as an unmatched close tag. `<svg>` still pairs nested `<svg>` elements.
7 changes: 7 additions & 0 deletions .changeset/tokenizer-text-fast-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/liquid-html-parser': patch
---

Tokenize runs of plain text in one step instead of one character at a time. Most characters can't start a token, so the tokenizer now jumps to the next one that can.

Tokens and ASTs are unchanged. On Dawn, Horizon and the base theme, `tokenize` is about 6× faster and `toLiquidHtmlAST`/`toLiquidAST` about 2× faster.
16 changes: 11 additions & 5 deletions packages/liquid-html-parser/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The parser calls these utilities while building the AST.

| Module | Owns | Does NOT do |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `document/tokenizer.ts` | Modal document tokenizer. Default mode: scan for `{%`, `%}`, `{{`, `}}`, `<`, `>`, `/>`, `</`, `<!--`, `-->`, `<!`, `---`. In HTML tag context: `=` emits `HtmlEquals`, quotes emit `HtmlQuoteOpen`/`HtmlQuoteClose`. Liquid delimiters take priority inside quoted attribute values. Produces flat `Token[]`. | Expression parsing. Tag-internal parsing. AST construction. |
| `document/tokenizer.ts` | Modal document tokenizer. Default mode: scan for `{%`, `%}`, `{{`, `}}`, `<`, `>`, `/>`, `</`, `<!--`, `<!`, `---`. In HTML comment context and in a Liquid raw tag's body: only Liquid delimiters (and the comment's close). In HTML tag context: `=` emits `HtmlEquals`, quotes emit `HtmlQuoteOpen`/`HtmlQuoteClose`, and each whitespace run is its own `Text` token. Liquid delimiters take priority inside quoted attribute values. Produces flat `Token[]`. | Expression parsing. Tag-internal parsing. AST construction. |
| `document/base.ts` | `ParserBase` class: shared token consumption primitives (`consume`, `accept`, `check`, `peek`, `advance`, `isAtEnd`, `getSource`, `tokenAt`, `tokenCount`, `getPosition`, `setPosition`). | Parsing logic. Node construction. |
| `document/parser.ts` | `DocumentParser extends ParserBase`. Implements all delegate interfaces. Owns parser-level state: `parseHtml`, `allowUnclosedHtml`, `allowUnclosedDocumentNode`, `inAttributeContext`, `inAttributeValueContext`. Delegates all parsing to free functions in sibling modules via typed interfaces. | Expression parsing (delegates to `MarkupParser`). Tag-specific markup (delegates to tag functions). Node construction (delegates to `document/factories.ts`). |
| `document/node-dispatch.ts` | `parseNode()`: exhaustive switch on `TokenType` routing to the appropriate parse function. `parseYamlFrontmatter()`. `advanceAsText()`. Defines the `NodeDispatchDelegate` interface. | Liquid tag parsing. HTML element parsing. Expression parsing. |
Expand Down Expand Up @@ -206,7 +206,7 @@ enum TokenType {
HtmlTagClose, // >
HtmlSelfClose, // />
HtmlCommentOpen, // <!--
HtmlCommentClose, // -->
HtmlCommentClose, // --> or --!>, or > / -> right after <!-- (only in a comment)
HtmlDoctypeOpen, // <! (not followed by --)

// HTML attribute tokens (emitted only in HTML tag context)
Expand All @@ -231,6 +231,10 @@ function tokenize(source: string): Token[];

The tokenizer is modal. After emitting `HtmlTagOpen` or `HtmlCloseTagOpen`, it enters **HTML tag context** where `=` and quotes are significant. It exits HTML tag context on `HtmlTagClose`, `HtmlSelfClose`, or any Liquid open delimiter. Inside quoted attribute values, Liquid delimiters (`{{`, `{%`) take priority and are emitted as their own tokens; the quote context resumes after the Liquid close delimiter.

After `HtmlCommentOpen` (`<!--`), the tokenizer enters **HTML comment context**, where only Liquid delimiters and the comment's close are significant and everything else is `Text`. A tag opener, a quote, a close tag, or a nested `<!--` in a comment can't open a mode that hides the close. The close is `-->` or `--!>`, or `>` or `->` right after `<!--` (the HTML spec's empty comments `<!-->` and `<!--->`). Outside a comment, `-->` is `Text`. Liquid inside a comment is still tokenized, because it still renders. Outside SVG, a `<script>` or `<style>` body is tokenized as raw text (only Liquid and its own close tag), so a `<!--` there is text. Inside an `<svg>` (from its open tag to the `</svg>` token that balances it, where the parser closes it, counting nested `<svg` open tags), `<!--` and `-->` tokenize as they did before comment mode existed and open no mode. An `<svg` glued to Liquid (`<svg{% if x %} class="x"{% endif %}>`) counts as an `<svg>`. The parser keeps an `<svg>` body raw and closes it at that `</svg>` token, and SVG can hold CDATA, `foreignObject` scripts, and other text where `<!--` isn't a comment.

A Liquid raw tag's body (`{% comment %}`, `{% raw %}`, `{% doc %}`, `{% schema %}`, …) is opaque to the tokenizer as well as the parser. `tokenize` takes the tag registry's body rules (`rawBodyRule`, built by `rawBodyRuleLookup`), and after the `%}` of a raw tag it ends the body with `findRawBodyEnd`, the same rule the parser uses. It tokenizes the body on its own as raw text (Liquid delimiters only), then resumes at the end tag in the mode the raw tag sat in. A `<!--`, `<b`, or quote in the body can't change the mode around it, and a stray `{{` in it can't run past the end tag. A body Liquid reports as never closed is tokenized as markup and left to the parser, which throws. `ParserBase` passes the same rules to every re-tokenize.

Tag name extraction is not tokenized. The parser slices the first word from the `Text` token content between `{%` and `%}`. No `LiquidTagName` token type.

When `parseHtml` is false (`toLiquidAST`), the parser ignores HTML token types and accumulates them into `TextNode`. One tokenizer, two parser modes.
Expand Down Expand Up @@ -428,7 +432,7 @@ function makeHtmlRawNode(
blockEndPosition,
source,
): HtmlRawNode;
function makeHtmlComment(body, start, end, source): HtmlComment;
function makeHtmlComment(children, blockStartPosition, blockEndPosition, source): HtmlComment;
function makeHtmlDoctype(legacyDoctypeString, start, end, source): HtmlDoctype;
function makeAttrDoubleQuoted(name, value, attributePosition, position, source): AttrDoubleQuoted;
function makeAttrSingleQuoted(name, value, attributePosition, position, source): AttrSingleQuoted;
Expand Down Expand Up @@ -594,6 +598,8 @@ All except `AttrEmpty` extend `AttributeNodeBase<T>` with `name: (TextNode | Liq

Defined in frozen `ast.ts`. Produced when a `</tagName>` close tag does not match any open element AND the current context has `allowUnclosedHtml: true` (i.e., inside `if`/`unless`/`case` branches). Has `name: (TextNode | LiquidVariableOutput)[]` and `blockStartPosition: Position`.

With the `pairDanglingCloseTags` option, it is also produced for a close tag that names the innermost element earlier `if`/`unless`/`case` tags in the same scope left open on every path (`document/branch-opened-elements.ts`, which keeps that open stack per scope and extends it as nodes are appended). That case is checked before the close tag is matched against the enclosing element.

---

## 5. Decisions Log
Expand All @@ -616,7 +622,7 @@ The parser internally uses `BinaryExpr { kind: 'comparison' | 'logical', left, o

### D5: Modal document tokenizer

After `<` (tag open), the tokenizer enters HTML tag context, emitting `HtmlEquals`, `HtmlQuoteOpen`, `HtmlQuoteClose`. Liquid delimiters take priority inside quoted values. This eliminates all character-level scanning in the parser for attributes.
After `<` (tag open), the tokenizer enters HTML tag context, emitting `HtmlEquals`, `HtmlQuoteOpen`, `HtmlQuoteClose`. Liquid delimiters take priority inside quoted values. After `<!--`, it enters HTML comment context, where only Liquid and the comment's close are tokens; a Liquid raw tag's body is tokenized on its own as raw text. This eliminates all character-level scanning in the parser for attributes.

Tag name extraction stays simple: slice first word from content between `{%` and `%}`. No `LiquidTagName` token.

Expand Down Expand Up @@ -745,7 +751,7 @@ liquid tag parse(name, markupParser, parser):

**Block nesting:** Block tags inside `{% liquid %}` consume subsequent lines until their matching `end*` line, reusing `finalizeBranch()`.

**Raw tags inside `{% liquid %}`:** When the line is a raw tag (e.g., `comment`), subsequent lines are consumed until `endcomment`.
**Raw tags inside `{% liquid %}`:** When the line is a raw tag (e.g., `comment`), subsequent lines are consumed until the line that ends its body under the tag's `body` rule (`findRawLineBodyEnd` in `raw-body.ts`); a `comment` nests, and a `raw` or `doc` body never closes there.

**Nested `{% liquid %}`:** Supported. Inner `liquid` tag processes its own body independently.

Expand Down
Loading
Loading