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
16 changes: 16 additions & 0 deletions .changeset/hand-written-liquid-html-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@shopify/liquid-html-parser': minor
'@shopify/theme-language-server-common': minor
'@shopify/theme-check-common': minor
'@shopify/prettier-plugin-liquid': minor
'@shopify/theme-graph': minor
---

Adopt the hand-written `liquid-html-parser` for performance

Replace the parser-combinator-based Liquid/HTML parser with a hand-written
recursive-descent parser. The new parser is significantly faster, adds
resilient parsing (it recovers from malformed input instead of bailing), and is
adapted to the theme-tools source model. `theme-language-server-common`,
`theme-check-common`, `prettier-plugin-liquid`, and `theme-graph` are updated to
consume the new parser.
26 changes: 26 additions & 0 deletions .changeset/port-theme-check-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@shopify/theme-check-common': minor
---

Port additional theme-check checks

Add the following checks:
- `LiquidComplexity` -- Reports Liquid files with high cyclomatic complexity (default threshold 120).
- `LiquidNestingDepth` -- Reports Liquid files with deeply nested control-flow structures (default max depth 10).
- `LiquidSyntaxError` -- Reports Liquid syntax errors.
- `MaxFileSize` -- Reports theme files that exceed Shopify's maximum per-file size.
- `ExcessiveSettingsCount` -- Reports section/block schemas declaring more top-level settings than the max (default 40).
- `BlockArgumentSettingCollision` -- Reports a plain block-tag argument whose name matches a setting id in the block's schema.
- `DuplicateBlockArguments` -- Reports duplicate argument names in a block tag.
- `MissingBlockArguments` -- Reports required `{% doc %}` arguments not provided on a block tag.
- `UnknownBlockSetting` -- Reports a `block.settings.<name>` argument where `<name>` is not in the block's schema.
- `UnrecognizedBlockArguments` -- Reports block-tag arguments not declared in the block's `{% doc %}` tag.
- `ValidBlockArgumentTypes` -- Reports type mismatches between block-tag arguments and their `{% doc %}` declarations.
- `SchemaOncePerFile` -- Reports when `{% schema %}` appears more than once in a file.
- `SchemaSectionOrBlockOnly` -- Reports `{% schema %}` used outside section or block files.
- `StylesheetOncePerFile` -- Reports when `{% stylesheet %}` appears more than once in a file.
- `StylesheetTagInWrongFile` -- Reports `{% stylesheet %}` used outside section, block, or snippet files.
- `JavascriptOncePerFile` -- Reports when `{% javascript %}` appears more than once in a file.
- `JavascriptTagInWrongFile` -- Reports `{% javascript %}` used outside section, block, or snippet files.

`RequiredLayoutThemeObject` now flags any `layout/*.liquid` file missing the required theme objects, not just `layout/theme.liquid`.
9 changes: 6 additions & 3 deletions packages/liquid-html-parser/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ pnpm-debug.log*
.DS_Store
dawn
TODO
grammar/liquid-html.ohm.js
standalone.js
standalone.js.LICENSE.txt
**/actual.liquid
coverage
.nyc_output

# Generated parser fixtures (goldens + downloaded themes) — regenerated by scripts/setup-fixtures.ts
fixtures/golden-html-ast/
fixtures/golden-liquid-ast/
fixtures/theme/
fixtures/theme-bundle.ts
15 changes: 0 additions & 15 deletions packages/liquid-html-parser/build/shims.js

This file was deleted.

52 changes: 52 additions & 0 deletions packages/liquid-html-parser/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Fixtures

Golden fixtures and theme files are gitignored (too large for PR diffs).

## Directory layout

- `theme/` -- raw `.liquid` files organized by theme (e.g. `theme/dawn/`, `theme/horizon/`)
- `theme-bundle.ts` -- auto-generated TypeScript module exporting all theme sources as an array
- `golden-html-ast/` -- JSON snapshots from `toLiquidHtmlAST` per theme
- `golden-liquid-ast/` -- JSON snapshots from `toLiquidAST` per theme

## Scripts

All scripts live in `../scripts/` and are run with `pnpx tsx`.

### Quick setup (all-in-one)

```
pnpx tsx scripts/setup-fixtures.ts
```

Downloads themes, generates the theme bundle, builds the parser, and generates golden fixtures. Idempotent — safe to re-run.

### 1. Download theme files

```
pnpx tsx scripts/download-themes.ts
```

Downloads Dawn (`Shopify/dawn`), Horizon (`Shopify/horizon`), and base-theme (`shopify-playground/ose-next-theme`) from GitHub using `gh api`. Extracts `.liquid` files into `fixtures/theme/<theme-name>/`. Requires `gh` CLI authenticated with repo access.

### 2. Generate theme bundle

```
pnpx tsx scripts/generate-theme-bundle.ts
```

Reads every `.liquid` file under `fixtures/theme/`, sorts them, and writes `fixtures/theme-bundle.ts` -- a TypeScript module that exports `THEME_FILES` (an array of `{ path, source }` objects). This lets tests import theme sources without filesystem access.

Requires `fixtures/theme/` to exist and contain at least one `.liquid` file.

### 3. Generate golden AST fixtures

```
pnpx tsx scripts/generate-golden.ts [path-to-parser-dist]
```

Parses every `.liquid` file under `fixtures/theme/` with both `toLiquidHtmlAST` and `toLiquidAST`, strips non-essential properties (positions, linked-list pointers, source), and writes one JSON file per template into `fixtures/golden-html-ast/<theme>/` and `fixtures/golden-liquid-ast/<theme>/`.

The optional argument overrides the parser module path (defaults to `dist/index.js`). Build the parser first (`pnpm run build`) when using the default.

Prints a per-theme success/failure summary on completion.
75 changes: 75 additions & 0 deletions packages/liquid-html-parser/fixtures/error-corpus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Adversarial error corpus for the tolerant-parser overhead benchmark.
*
* Every `source` here is *invalid* Liquid/HTML: the strict `toLiquidHtmlAST`
* throws on each one, so these sources exist only to exercise the tolerant
* path (`toTolerantLiquidHtmlAST`), which recovers instead of throwing and
* surfaces one `LiquidErrorNode` per region it gives up on.
*
* The bench arm that consumes this corpus measures resync/recovery cost, not
* throughput on clean input.
*
* Shape is identical to `THEME_FILES` in `theme-bundle.ts`
* (`Array<{ path; source }>`) so the bench loop stays uniform across corpora.
*
* Contract (verified in C2): each `source` passed to
* `toTolerantLiquidHtmlAST` returns a `DocumentNode` without throwing and
* contains at least one `LiquidErrorNode`.
*/

/*
* A single orphan close tag followed by a valid variable output. The close
* has no matching open, so the tolerant parser emits one error node, then
* resynchronizes on the next construct-open boundary and recovers the output.
*
* Repeating this unit forces the resync loop to fire once per unit — the
* "error every few tokens" density stress.
*/
const FREQUENT_ERROR_UNIT = "{% endfor %}{{ x }}{% endif %}{{ y }}";

/*
* A well-formed Liquid+HTML fragment the strict parser accepts as-is.
*
* Repeated many times it builds a large clean body; a single orphan close
* tag appended near EOF then costs exactly one recovery, proving tail
* recovery does not rescan the whole document.
*/
const CLEAN_UNIT =
'<div class="card">{{ product.title }}' +
"{% if product.available %}<span>{{ product.price }}</span>{% endif %}" +
"</div>\n";

export const ERROR_FILES: Array<{ path: string; source: string }> = [
{
/* Seeded verbatim from tolerant.test.ts:150 — one orphan close tag. */
path: "error-corpus/single-error.liquid",
source: "{% endfor %}",
},
{
/*
* Seeded verbatim from tolerant.test.ts:178 — two orphan closes with a
* valid output recovered between them (interleaved resync).
*/
path: "error-corpus/interleaved-resync.liquid",
source: "{% endfor %}{{ good }}{% endif %}",
},
{
/*
* Net-new: an orphan close roughly every few tokens across a moderately
* long source, bounding worst-case resync frequency.
*
* 60 units yields 120 error nodes interleaved with 120 recovered
* outputs.
*/
path: "error-corpus/pathological-frequent.liquid",
source: FREQUENT_ERROR_UNIT.repeat(60),
},
{
/*
* Net-new: a large valid body (150 clean units) with a single orphan
* close tag just before EOF — "parse a lot, then recover once".
*/
path: "error-corpus/large-clean-error-near-eof.liquid",
source: CLEAN_UNIT.repeat(150) + "{% endfor %}",
},
];
Loading
Loading