From bda435a25c738e3526beac797ee3770564d78639 Mon Sep 17 00:00:00 2001 From: Patrick Dubroy Date: Tue, 17 Feb 2026 19:32:37 +0100 Subject: [PATCH 1/3] Update CI and tsconfig for Node 20 / ESM module resolution - Drop Node 18 from CI test matrix (keep only Node 20) - Change root tsconfig to target es2020, module node20 - Add moduleResolution: node overrides to codemirror-language-client and lang-jsonc to preserve their existing resolution behavior - Add skipLibCheck to vscode-extension tsconfig --- .github/workflows/ci.yml | 2 +- packages/codemirror-language-client/tsconfig.json | 1 + packages/lang-jsonc/tsconfig.json | 1 + packages/vscode-extension/tsconfig.json | 1 + tsconfig.json | 6 +++--- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a9f89cd..610b821e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] - node-version: [18, 20] + node-version: [20] name: Tests / OS ${{ matrix.os }} / NodeJS ${{ matrix.node-version }} diff --git a/packages/codemirror-language-client/tsconfig.json b/packages/codemirror-language-client/tsconfig.json index 48524f58b..b27871616 100644 --- a/packages/codemirror-language-client/tsconfig.json +++ b/packages/codemirror-language-client/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist/esm", "tsBuildInfoFile": "./dist/esm/tsconfig.tsbuildInfo", "module": "es6", + "moduleResolution": "node", "rootDir": "src", "resolveJsonModule": false, "lib": [ diff --git a/packages/lang-jsonc/tsconfig.json b/packages/lang-jsonc/tsconfig.json index 48524f58b..b27871616 100644 --- a/packages/lang-jsonc/tsconfig.json +++ b/packages/lang-jsonc/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist/esm", "tsBuildInfoFile": "./dist/esm/tsconfig.tsbuildInfo", "module": "es6", + "moduleResolution": "node", "rootDir": "src", "resolveJsonModule": false, "lib": [ diff --git a/packages/vscode-extension/tsconfig.json b/packages/vscode-extension/tsconfig.json index 0e3a7ac62..1743a46d5 100644 --- a/packages/vscode-extension/tsconfig.json +++ b/packages/vscode-extension/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "skipLibCheck": true, "module": "commonjs", "target": "ES2020", "lib": ["ES2020"], diff --git a/tsconfig.json b/tsconfig.json index 7cc798d6d..2824441df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "lib": [ "es2019" ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ @@ -24,9 +24,9 @@ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "node20", /* Specify what module code is generated. */ "rootDir": ".", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { "@shopify/theme-check-common": [ From d637ad19f3c8e7abc93345a250458ab307c9471f Mon Sep 17 00:00:00 2001 From: Patrick Dubroy Date: Tue, 17 Feb 2026 20:51:52 +0100 Subject: [PATCH 2/3] Upgrade to ohm-js v18 (beta.7) with @ohm-js/compiler and to-ast-compat Co-Authored-By: Claude Opus 4.6 --- .changeset/ohm-wasm-browser-parsing.md | 5 + packages/liquid-html-parser/package.json | 4 +- packages/liquid-html-parser/src/errors.ts | 8 +- .../liquid-html-parser/src/grammar.spec.ts | 6 +- packages/liquid-html-parser/src/grammar.ts | 2 +- .../liquid-html-parser/src/stage-1-cst.ts | 238 +++++++++--------- .../liquid-html-syntax-error/index.spec.ts | 4 +- yarn.lock | 18 +- 8 files changed, 146 insertions(+), 139 deletions(-) create mode 100644 .changeset/ohm-wasm-browser-parsing.md diff --git a/.changeset/ohm-wasm-browser-parsing.md b/.changeset/ohm-wasm-browser-parsing.md new file mode 100644 index 000000000..6fd34429a --- /dev/null +++ b/.changeset/ohm-wasm-browser-parsing.md @@ -0,0 +1,5 @@ +--- +'@shopify/liquid-html-parser': minor +--- + +Integrate @ohm-js/wasm for browser-compatible parsing diff --git a/packages/liquid-html-parser/package.json b/packages/liquid-html-parser/package.json index 6e523db09..bd7236cf6 100644 --- a/packages/liquid-html-parser/package.json +++ b/packages/liquid-html-parser/package.json @@ -32,8 +32,10 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@ohm-js/compiler": "18.0.0-beta.7", + "@ohm-js/to-ast-compat": "18.0.0-beta.7", "line-column": "^1.0.2", - "ohm-js": "^17.0.0" + "ohm-js": "18.0.0-beta.7" }, "devDependencies": { "@types/line-column": "^1.0.0" diff --git a/packages/liquid-html-parser/src/errors.ts b/packages/liquid-html-parser/src/errors.ts index 254604455..6f1a37a00 100644 --- a/packages/liquid-html-parser/src/errors.ts +++ b/packages/liquid-html-parser/src/errors.ts @@ -1,5 +1,5 @@ import lineColumn from 'line-column'; -import { MatchResult } from 'ohm-js'; +import { FailedMatchResult } from 'ohm-js'; import { NodeTypes, Position } from './types'; interface LineColPosition { @@ -10,12 +10,12 @@ interface LineColPosition { export class LiquidHTMLCSTParsingError extends SyntaxError { loc?: { start: LineColPosition; end: LineColPosition }; - constructor(ohm: MatchResult) { + constructor(ohm: FailedMatchResult) { super(ohm.shortMessage); this.name = 'LiquidHTMLParsingError'; - const input = (ohm as any).input; - const errorPos = (ohm as any)._rightmostFailurePosition; + const input = ohm.input; + const errorPos = ohm.getRightmostFailurePosition(); const lineCol = lineColumn(input).fromIndex(Math.min(errorPos, input.length - 1)); // Plugging ourselves into @babel/code-frame since this is how diff --git a/packages/liquid-html-parser/src/grammar.spec.ts b/packages/liquid-html-parser/src/grammar.spec.ts index 01e08a8d0..0cb27eafb 100644 --- a/packages/liquid-html-parser/src/grammar.spec.ts +++ b/packages/liquid-html-parser/src/grammar.spec.ts @@ -101,7 +101,7 @@ describe('Unit: liquidHtmlGrammar', () => { expectMatchSucceeded('<6h>').to.be.false; function expectMatchSucceeded(text: string) { - const match = grammar.LiquidHTML.match(text, 'Node'); + using match = grammar.LiquidHTML.match(text, 'Node'); return expect(match.succeeded(), text); } }); @@ -129,7 +129,7 @@ describe('Unit: liquidHtmlGrammar', () => { `).to.be.true; function expectMatchSucceeded(text: string) { - const match = grammar.LiquidStatement.match(text.trimStart(), 'Node'); + using match = grammar.LiquidStatement.match(text.trimStart(), 'Node'); return expect(match.succeeded(), text); } }); @@ -156,7 +156,7 @@ describe('Unit: liquidHtmlGrammar', () => { }); function expectMatchSucceeded(text: string) { - const match = placeholderGrammars.LiquidHTML.match(text.trimStart(), 'Node'); + using match = placeholderGrammars.LiquidHTML.match(text.trimStart(), 'Node'); return expect(match.succeeded(), text); } }); diff --git a/packages/liquid-html-parser/src/grammar.ts b/packages/liquid-html-parser/src/grammar.ts index 2d91f00fd..fb9502da7 100644 --- a/packages/liquid-html-parser/src/grammar.ts +++ b/packages/liquid-html-parser/src/grammar.ts @@ -1,4 +1,4 @@ -import { grammars, Grammar } from 'ohm-js'; +import { grammars, Grammar } from '@ohm-js/compiler/compat'; export const liquidHtmlGrammars = grammars(require('../grammar/liquid-html.ohm.js')); diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index f28bd0891..d4d60f073 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -31,8 +31,9 @@ */ import { Parser } from 'prettier'; -import { Grammar, Node } from 'ohm-js'; -import { toAST } from 'ohm-js/extras'; +import { Grammar } from '@ohm-js/compiler/compat'; +import { createToAst, AstMapping as Mapping } from '@ohm-js/to-ast-compat'; +import { CstNode as Node, OptNode, MatchResult } from 'ohm-js'; import { LiquidDocGrammar, LiquidGrammars, @@ -508,21 +509,6 @@ export type LiquidDocConcreteNode = | ConcreteLiquidDocDescriptionNode | ConcreteLiquidDocPromptNode; -interface Mapping { - [k: string]: number | TemplateMapping | TopLevelFunctionMapping; -} - -interface TemplateMapping { - type: ConcreteNodeTypes; - locStart: (node: Node[]) => number; - locEnd: (node: Node[]) => number; - source: string; - [k: string]: FunctionMapping | string | number | boolean | object | null; -} - -type TopLevelFunctionMapping = (...nodes: Node[]) => any; -type FunctionMapping = (nodes: Node[]) => any; - const markup = (i: number) => (tokens: Node[]) => tokens[i].sourceString.trim(); const markupTrimEnd = (i: number) => (tokens: Node[]) => tokens[i].sourceString.trimEnd(); @@ -577,24 +563,26 @@ function toCST( // for the offset of the {% liquid %} markup const locStart = (tokens: Node[]) => offset + tokens[0].source.startIdx; const locEnd = (tokens: Node[]) => offset + tokens[tokens.length - 1].source.endIdx; - const locEndSecondToLast = (tokens: Node[]) => offset + tokens[tokens.length - 2].source.endIdx; const textNode = { type: ConcreteNodeTypes.TextNode, - value: function () { - return (this as any).sourceString; + value: function (this: Node) { + return this.sourceString; }, locStart, locEnd, source, }; - const res = grammar.match(matchingSource, 'Node'); - if (res.failed()) { - throw new LiquidHTMLCSTParsingError(res); + using match = grammar.match(matchingSource, 'Node'); + if (match.failed()) { + throw new LiquidHTMLCSTParsingError(match); } - const HelperMappings: Mapping = { + // toAst is declared here so that mapping functions can reference it via closure. + let toAst: (nodeOrResult: Node | MatchResult) => T; + + const HelperMappings: Mapping = { Node: 0, TextNode: textNode, orderedListOf: 0, @@ -602,14 +590,11 @@ function toCST( empty: () => null, nonemptyOrderedListOf: 0, nonemptyOrderedListOfBoth(nonemptyListOfA: Node, _sep: Node, nonemptyListOfB: Node) { - const self = this as any; - return nonemptyListOfA - .toAST(self.args.mapping) - .concat(nonemptyListOfB.toAST(self.args.mapping)); + return (toAst(nonemptyListOfA) as T[]).concat(toAst(nonemptyListOfB) as T[]); }, }; - const LiquidMappings: Mapping = { + const LiquidMappings: Mapping = { liquidNode: 0, liquidRawTag: 0, liquidRawTagImpl: { @@ -734,7 +719,7 @@ function toCST( const markupNode = nodes[6]; const nameNode = nodes[3]; if (NamedTags.hasOwnProperty(nameNode.sourceString)) { - return markupNode.toAST((this as any).args.mapping); + return toAst(markupNode); } return markupNode.sourceString.trim(); }, @@ -753,8 +738,13 @@ function toCST( type: ConcreteNodeTypes.ForMarkup, variableName: 0, collection: 4, - reversed: 6, - args: 8, + reversed: (children: Node[]) => { + return (children[5] as OptNode).ifPresent( + (_space: Node, reversed: Node) => reversed.sourceString.trim(), + () => null, + ); + }, + args: 7, locStart, locEnd, source, @@ -830,7 +820,7 @@ function toCST( const markupNode = nodes[6]; const nameNode = nodes[3]; if (NamedTags.hasOwnProperty(nameNode.sourceString)) { - return markupNode.toAST((this as any).args.mapping); + return toAst(markupNode); } return markupNode.sourceString.trim(); }, @@ -868,8 +858,15 @@ function toCST( liquidTagCycleMarkup: { type: ConcreteNodeTypes.CycleMarkup, - groupName: 0, - args: 3, + groupName: (tokens: Node[]) => { + // The optional group (liquidExpression ":")? has 2 children when matched. + // We need to explicitly handle this with ifPresent to get the expression. + return (tokens[0] as OptNode).ifPresent( + (expr: Node, _colon: Node) => toAst(expr), + () => null, + ); + }, + args: 2, locStart, locEnd, source, @@ -897,21 +894,21 @@ function toCST( }, renderArguments: 1, completionModeRenderArguments: function ( - _0, - namedArguments, - _2, - _3, - _4, - _5, - variableLookup, - _7, + _argSepOptComma: Node, + tagArguments: Node, + _optComma: Node, + _space: Node, + opt: Node, ) { - const self = this as any; - - // variableLookup.sourceString can be '' when there are no incomplete params - return namedArguments - .toAST(self.args.mapping) - .concat(variableLookup.sourceString === '' ? [] : variableLookup.toAST(self.args.mapping)); + const namedArgs = toAst(tagArguments) as T[]; + + return namedArgs.concat( + (opt as OptNode).ifPresent( + (_argSep: Node, variableLookup: Node, _spaceAfter: Node) => + variableLookup.sourceString === '' ? [] : ([toAst(variableLookup)] as T[]), + () => [], + ), + ); }, snippetExpression: 0, renderVariableExpression: { @@ -949,11 +946,9 @@ function toCST( expression: 0, filters: 1, rawSource: (tokens: Node[]) => - source.slice(locStart(tokens), tokens[tokens.length - 2].source.endIdx).trimEnd(), + source.slice(locStart(tokens), tokens[tokens.length - 1].source.endIdx).trimEnd(), locStart, - // The last node of this rule is a positive lookahead, we don't - // want its endIdx, we want the endIdx of the previous one. - locEnd: locEndSecondToLast, + locEnd, source, }, @@ -964,35 +959,34 @@ function toCST( locEnd, source, args(nodes: Node[]) { - // Traditinally, this would get transformed into null or array. But - // it's better if we have an empty array instead of null here. - if (nodes[7].sourceString === '') { - return []; - } else { - return nodes[7].toAST((this as any).args.mapping); - } + return (nodes[4] as OptNode).ifPresent( + (_space1: Node, _colon: Node, _space2: Node, args: Node, _optComma: Node) => + toAst(args), + () => [], + ); }, }, filterArguments: 0, arguments: 0, - complexArguments: function (completeParams, _space1, _comma, _space2, incompleteParam) { - const self = this as any; - - return completeParams - .toAST(self.args.mapping) - .concat( - incompleteParam.sourceString === '' ? [] : incompleteParam.toAST(self.args.mapping), - ); + complexArguments: function (completeParams: Node, opt: Node) { + return (toAst(completeParams) as T[]).concat( + (opt as OptNode).ifPresent( + (_space1: Node, _comma: Node, _space2: Node, incompleteParam: Node) => + toAst(incompleteParam) as T[], + () => [], + ), + ); }, simpleArgument: 0, tagArguments: 0, contentForTagArgument: 0, - completionModeContentForTagArgument: function (namedArguments, _separator, variableLookup) { - const self = this as any; - - return namedArguments - .toAST(self.args.mapping) - .concat(variableLookup.sourceString === '' ? [] : variableLookup.toAST(self.args.mapping)); + completionModeContentForTagArgument: function (namedArguments: Node, opt: Node) { + return (toAst(namedArguments) as T[]).concat( + (opt as OptNode).ifPresent( + (_separator: Node, variableLookup: Node) => toAst(variableLookup) as T[], + () => [], + ), + ); }, positionalArgument: 0, namedArgument: { @@ -1006,20 +1000,18 @@ function toCST( contentForNamedArgument: { type: ConcreteNodeTypes.NamedArgument, - name: (node) => node[0].sourceString + node[1].sourceString, - value: 6, + name: (node: Node[]) => node[0].sourceString + node[1].sourceString, + value: 5, locStart, locEnd, source, }, liquidBooleanExpression(initialCondition: Node, subsequentConditions: Node) { - const initialConditionAst = initialCondition.toAST( - (this as any).args.mapping, - ) as ConcreteLiquidCondition; - const subsequentConditionAsts = subsequentConditions.toAST( - (this as any).args.mapping, - ) as ConcreteLiquidCondition[]; + const initialConditionAst = toAst(initialCondition) as unknown as ConcreteLiquidCondition; + const subsequentConditionAsts = toAst( + subsequentConditions, + ) as unknown as ConcreteLiquidCondition[]; // liquidBooleanExpression can capture too much. If there are no comparisons (e.g. `==`, `>`, etc.) // and we only have a single condition (i.e. no `and` or `or` operators), we can return the expression directly. @@ -1136,7 +1128,7 @@ function toCST( tagMarkup: (n: Node) => n.sourceString.trim(), }; - const LiquidStatement: Mapping = { + const LiquidStatement: Mapping = { LiquidStatement: 0, liquidTagOpenRule: { type: ConcreteNodeTypes.LiquidTagOpen, @@ -1145,14 +1137,14 @@ function toCST( const markupNode = nodes[2]; const nameNode = nodes[0]; if (NamedTags.hasOwnProperty(nameNode.sourceString)) { - return markupNode.toAST((this as any).args.mapping); + return toAst(markupNode); } return markupNode.sourceString.trim(); }, whitespaceStart: null, whitespaceEnd: null, locStart, - locEnd: locEndSecondToLast, + locEnd, source, }, @@ -1162,7 +1154,7 @@ function toCST( whitespaceStart: null, whitespaceEnd: null, locStart, - locEnd: locEndSecondToLast, + locEnd, source, }, @@ -1173,14 +1165,14 @@ function toCST( const markupNode = nodes[2]; const nameNode = nodes[0]; if (NamedTags.hasOwnProperty(nameNode.sourceString)) { - return markupNode.toAST((this as any).args.mapping); + return toAst(markupNode); } return markupNode.sourceString.trim(); }, whitespaceStart: null, whitespaceEnd: null, locStart, - locEnd: locEndSecondToLast, + locEnd, source, }, @@ -1188,7 +1180,7 @@ function toCST( type: ConcreteNodeTypes.LiquidRawTag, name: 0, body: 4, - children(nodes) { + children(nodes: Node[]) { return toCST( source, grammars, @@ -1203,7 +1195,7 @@ function toCST( delimiterWhitespaceStart: null, delimiterWhitespaceEnd: null, locStart, - locEnd: locEndSecondToLast, + locEnd, source, blockStartLocStart: (tokens: Node[]) => offset + tokens[0].source.startIdx, blockStartLocEnd: (tokens: Node[]) => offset + tokens[2].source.endIdx, @@ -1222,7 +1214,7 @@ function toCST( // We're stripping the newline from the statementSep, that's why we // slice(1). Since statementSep = newline (space | newline)* tokens[1].sourceString.slice(1) + tokens[2].sourceString, - children(tokens) { + children(tokens: Node[]) { const commentSource = tokens[1].sourceString.slice(1) + tokens[2].sourceString; return toCST( source, @@ -1242,8 +1234,8 @@ function toCST( source, blockStartLocStart: (tokens: Node[]) => offset + tokens[0].source.startIdx, blockStartLocEnd: (tokens: Node[]) => offset + tokens[0].source.endIdx, - blockEndLocStart: (tokens: Node[]) => offset + tokens[4].source.startIdx, - blockEndLocEnd: (tokens: Node[]) => offset + tokens[4].source.endIdx, + blockEndLocStart: (tokens: Node[]) => offset + tokens[3].source.startIdx, + blockEndLocEnd: (tokens: Node[]) => offset + tokens[3].source.endIdx, }, liquidInlineComment: { @@ -1253,18 +1245,17 @@ function toCST( whitespaceStart: null, whitespaceEnd: null, locStart, - locEnd: locEndSecondToLast, + locEnd, source, }, }; - const LiquidHTMLMappings: Mapping = { + const LiquidHTMLMappings: Mapping = { Node(frontmatter: Node, nodes: Node) { - const self = this as any; const frontmatterNode = - frontmatter.sourceString.length === 0 ? [] : [frontmatter.toAST(self.args.mapping)]; + frontmatter.sourceString.length === 0 ? [] : [toAst(frontmatter) as T]; - return frontmatterNode.concat(nodes.toAST(self.args.mapping)); + return frontmatterNode.concat(toAst(nodes) as T[]); }, yamlFrontmatter: { @@ -1295,8 +1286,7 @@ function toCST( type: ConcreteNodeTypes.HtmlRawTag, name: (tokens: Node[]) => tokens[0].children[1].sourceString, attrList(tokens: Node[]) { - const mappings = (this as any).args.mapping; - return tokens[0].children[2].toAST(mappings); + return toAst(tokens[0].children[2]); }, body: (tokens: Node[]) => source.slice(tokens[0].source.endIdx, tokens[2].source.startIdx), children: (tokens: Node[]) => { @@ -1322,7 +1312,7 @@ function toCST( HtmlVoidElement: { type: ConcreteNodeTypes.HtmlVoidElement, name: 1, - attrList: 3, + attrList: 2, locStart, locEnd, source, @@ -1359,8 +1349,7 @@ function toCST( trailingTagNamePart: 0, trailingTagNameTextNode: textNode, tagName(leadingPart: Node, trailingParts: Node) { - const mappings = (this as any).args.mapping; - return [leadingPart.toAST(mappings)].concat(trailingParts.toAST(mappings)); + return [toAst(leadingPart) as T].concat(toAst(trailingParts) as T[]); }, AttrUnquoted: { @@ -1423,46 +1412,50 @@ function toCST( {}, ); - return toAST(res, selectedMappings) as T; + toAst = createToAst(selectedMappings); + return toAst(match); } + /** * Builds an AST for LiquidDoc content. * * `toCST` includes mappings and logic that are not needed for LiquidDoc so we're separating this logic */ function toLiquidDocAST(source: string, matchingSource: string, offset: number) { + type T = LiquidDocConcreteNode; + // When we switch parser, our locStart and locEnd functions must account // for the offset of the {% doc %} markup const locStart = (tokens: Node[]) => offset + tokens[0].source.startIdx; const locEnd = (tokens: Node[]) => offset + tokens[tokens.length - 1].source.endIdx; - const res = LiquidDocGrammar.match(matchingSource, 'Node'); - if (res.failed()) { - throw new LiquidHTMLCSTParsingError(res); + using match = LiquidDocGrammar.match(matchingSource, 'Node'); + if (match.failed()) { + throw new LiquidHTMLCSTParsingError(match); } + // toAst is declared here so that mapping functions can reference it via closure. + let toAst: (nodeOrResult: Node | MatchResult) => T; + /** * Reusable text node type */ const textNode = () => ({ type: ConcreteNodeTypes.TextNode, - value: function () { - return (this as any).sourceString; + value: function (this: Node) { + return this.sourceString; }, locStart, locEnd, source, }); - const LiquidDocMappings: Mapping = { + const LiquidDocMappings: Mapping = { Node(implicitDescription: Node, body: Node) { - const self = this as any; - const implicitDescriptionNode = - implicitDescription.sourceString.length === 0 - ? [] - : [implicitDescription.toAST(self.args.mapping)]; - return implicitDescriptionNode.concat(body.toAST(self.args.mapping)); + const implicitDescriptionNode: T[] = + implicitDescription.sourceString.length === 0 ? [] : [toAst(implicitDescription) as T]; + return implicitDescriptionNode.concat(toAst(body) as unknown as T[]); }, ImplicitDescription: { type: ConcreteNodeTypes.LiquidDocDescriptionNode, @@ -1483,7 +1476,7 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number) source, paramType: 2, paramName: 4, - paramDescription: 8, + paramDescription: 7, }, descriptionNode: { type: ConcreteNodeTypes.LiquidDocDescriptionNode, @@ -1493,9 +1486,7 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number) source, content: 2, isImplicit: false, - isInline: function (this: Node) { - return !this.children[1].sourceString.includes('\n'); - }, + isInline: (children: Node[]) => !children[1].sourceString.includes('\n'), }, descriptionContent: textNode(), paramType: 2, @@ -1524,9 +1515,7 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number) locEnd, source, content: 2, - isInline: function (this: Node) { - return !this.children[1].sourceString.includes('\n'); - }, + isInline: (children: Node[]) => !children[1].sourceString.includes('\n'), }, promptNode: { type: ConcreteNodeTypes.LiquidDocPromptNode, @@ -1541,5 +1530,6 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number) fallbackNode: textNode(), }; - return toAST(res, LiquidDocMappings); + toAst = createToAst(LiquidDocMappings); + return toAst(match); } diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts index 566b273c8..bb05dbf49 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts @@ -75,7 +75,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal(`SyntaxError: expected ">", not """`); + expect(offenses[0].message).to.equal(`SyntaxError: expected "{{", not """, "{%", "/>", or ">"`); }); it('should report unexpected tokens (3)', async () => { @@ -86,7 +86,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", "comment", or "doc"`, + `SyntaxError: expected "doc", "comment", "raw", "javascript", "schema", "stylesheet", "style", "end", "case", "capture", "form", "for", "tablerow", "if", "paginate", "unless", "ifchanged", "assign", "break", "continue", "cycle", "content_for", "decrement", "echo", "else", "elsif", "include", "increment", "layout", "liquid", "render", "section", "sections", "when", a letter, or "#"`, ); }); diff --git a/yarn.lock b/yarn.lock index 826ef7afc..f52d1463d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -909,6 +909,16 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@ohm-js/compiler@18.0.0-beta.7": + version "18.0.0-beta.7" + resolved "https://registry.yarnpkg.com/@ohm-js/compiler/-/compiler-18.0.0-beta.7.tgz#815ef1fdc1c8d67d268af412a09866d298c830e9" + integrity sha512-0WzEdoMKwqKHuSyoXmQErdpI7TuvfEyJmIGOY6ASftHX6oLlHAviCsvbHsGFr8S6vhwJZlOUSAeFeuVuNO1yaA== + +"@ohm-js/to-ast-compat@18.0.0-beta.7": + version "18.0.0-beta.7" + resolved "https://registry.yarnpkg.com/@ohm-js/to-ast-compat/-/to-ast-compat-18.0.0-beta.7.tgz#075ea5637a0705f36fe5074da1b18c49b5d621da" + integrity sha512-xjVtVQLs+zQfphkMtziOC9tD5C32+TzAILxbzbNmX0kTmX49GqgmmhnD6FQN105JIQLLgAPI6IiJparINU27bw== + "@playwright/browser-chromium@^1.47.2": version "1.48.0" resolved "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.48.0.tgz#780ca1ebe1aa34dbffa117267c5a84077ed784d0" @@ -5687,10 +5697,10 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -ohm-js@^17.0.0: - version "17.2.1" - resolved "https://registry.npmjs.org/ohm-js/-/ohm-js-17.2.1.tgz#77d7d4910134c9e3c7a1b068a878499c68edf92f" - integrity sha512-4cXF0G09fAYU9z61kTfkNbKK1Kz/sGEZ5NbVWHoe9Qi7VB7y+Spwk051CpUTfUENdlIr+vt8tMV4/LosTE2cDQ== +ohm-js@18.0.0-beta.7: + version "18.0.0-beta.7" + resolved "https://registry.yarnpkg.com/ohm-js/-/ohm-js-18.0.0-beta.7.tgz#c2a336d065ee380277d9c8a2b1be7b1db17b15e2" + integrity sha512-tiaU/rb57ADd5aILVhHdwVwPaA74T3kS/z5NN8HbdiaPDbAe8yAli1XrYks1r4R5c9jr/NhvFD6seKZY2j9PSA== on-finished@2.4.1, on-finished@^2.3.0: version "2.4.1" From 12724d0af216a0bd083722b2cb95ba15f8086c82 Mon Sep 17 00:00:00 2001 From: Patrick Dubroy Date: Tue, 17 Feb 2026 20:51:57 +0100 Subject: [PATCH 3/3] Fix cleanErrorMessage regex (`not` is not guaranteed to come last) --- .../src/checks/liquid-html-syntax-error/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.ts index 03b4a31d3..ae1b4ffc7 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.ts @@ -25,7 +25,7 @@ function isParsingErrorWithLocation( function cleanErrorMessage(message: string, highlight: string): string { return message .replace(/Line \d+, col \d+:\s+/, 'SyntaxError: ') - .replace(/(?!