Created
December 2, 2024 15:00
-
-
Save ceifa/348bd476449765d866a6dc9489c53a8f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type * as monaco from 'monaco-editor' | |
import { ZodArray, ZodBoolean, ZodNumber, ZodObject, type ZodSchema, ZodString, type ZodTypeAny } from 'zod' | |
let isRegistered = false | |
export const registerLiquid = (monacoInstance: typeof monaco, schemas: Record<string, ZodSchema<unknown>>) => { | |
if (isRegistered) { | |
return | |
} | |
isRegistered = true | |
monacoInstance.languages.register({ id: 'liquid' }) | |
monacoInstance.languages.registerHoverProvider('liquid', { | |
provideHover: (model, position) => { | |
const variablePath = getVariablePathAtPosition(model, position) | |
if (variablePath) { | |
// Split the path by dots and brackets, filter out empty strings | |
const pathSegments = variablePath.split(/\.|\[|\]/).filter((segment) => segment !== '') | |
const variableName = pathSegments[0] | |
if (Object.prototype.hasOwnProperty.call(schemas, variableName)) { | |
const schema = schemas[variableName] | |
const pathWithoutVariableName = pathSegments.slice(1) | |
const typeInfo = getTypeInfoFromSchema(schema, pathWithoutVariableName) | |
if (typeInfo) { | |
return { | |
range: model.getWordAtPosition(position) | |
? new monacoInstance.Range( | |
position.lineNumber, | |
model.getWordAtPosition(position)!.startColumn, | |
position.lineNumber, | |
model.getWordAtPosition(position)!.endColumn, | |
) | |
: undefined, | |
contents: [{ value: '**Type**' }, { value: `\`${typeInfo}\`` }], | |
} | |
} | |
} | |
} | |
return null | |
}, | |
}) | |
monacoInstance.languages.setMonarchTokensProvider('liquid', { | |
defaultToken: '', | |
tokenPostfix: '.liquid', | |
brackets: [ | |
{ open: '{%', close: '%}', token: 'delimiter.tag' }, | |
{ open: '{{', close: '}}', token: 'delimiter.output' }, | |
{ open: '{', close: '}', token: 'delimiter.bracket' }, | |
{ open: '(', close: ')', token: 'delimiter.parenthesis' }, | |
{ open: '[', close: ']', token: 'delimiter.array' }, | |
], | |
tokenizer: { | |
root: [ | |
// Comments | |
[/{%\s*comment\s*%}/, { token: 'comment.block.start', next: '@commentBlock' }], | |
// Raw blocks | |
[/{%\s*raw\s*%}/, { token: 'string.raw.start', next: '@rawBlock' }], | |
// Output tags | |
[/\{\{/, { token: 'delimiter.output', next: '@output' }], | |
// Control flow tags | |
[ | |
/{%\s*(if|unless|elsif|else|case|when|for|tablerow|end\w+)\b/, | |
{ token: 'keyword.control', next: '@tag' }, | |
], | |
// Other tags | |
[ | |
/{%\s*(assign|capture|increment|decrement|include|render|cycle|break|continue)\b/, | |
{ token: 'keyword', next: '@tag' }, | |
], | |
// Default tag handler | |
[/{%/, { token: 'delimiter.tag', next: '@tag' }], | |
// Text | |
[/[^<{]+/, ''], | |
// HTML tags | |
[/<\/?[\w\-]+.*?>/, 'tag'], | |
// Remaining characters | |
[/[<{]/, ''], | |
], | |
output: [[/\}\}/, { token: 'delimiter.output', next: '@pop' }], { include: '@liquidExpression' }], | |
tag: [[/\%}/, { token: 'delimiter.tag', next: '@pop' }], { include: '@liquidExpression' }], | |
commentBlock: [ | |
[/{%\s*endcomment\s*%}/, { token: 'comment.block.end', next: '@pop' }], | |
[/./, 'comment.content'], | |
], | |
rawBlock: [ | |
[/{%\s*endraw\s*%}/, { token: 'string.raw.end', next: '@pop' }], | |
[/./, 'string.raw'], | |
], | |
liquidExpression: [ | |
// Whitespace | |
[/\s+/, 'white'], | |
// Operators | |
[/==|!=|<=|>=|<|>|and|or|contains/, 'operator'], | |
// Numbers | |
[/\b\d+(\.\d+)?\b/, 'number'], | |
// Strings | |
[/"(\\.|[^"\\])*"/, 'string'], | |
[/'(\\.|[^'\\])*'/, 'string'], | |
// Booleans and nil | |
[/\b(true|false|nil)\b/, 'constant.language'], | |
// Keywords | |
[ | |
/\b(assign|capture|endcapture|increment|decrement|if|endif|unless|endunless|case|endcase|when|else|elsif|for|endfor|include|with|break|continue|cycle|in|limit|offset|render|tablerow|endtablerow|comment|endcomment|raw|endraw|paginate|endpaginate|form|endform)\b/, | |
'keyword', | |
], | |
// Filters | |
[/\|/, { token: 'operator', next: '@filter' }], | |
// Variables and properties | |
[/[a-zA-Z_][\w\-]*/, 'variable'], | |
// Punctuation | |
[/[\[\]().,]/, 'delimiter'], | |
// Other | |
[/./, ''], | |
], | |
filter: [ | |
// Filter name | |
[/\s*(\w+)/, 'function'], | |
// Parameters | |
[/:/, 'operator', '@filterargs'], | |
// Next filter or end | |
[/\|/, 'operator', '@pop'], | |
[/\%}|\}\}/, { token: '@rematch', next: '@pop' }], | |
], | |
filterargs: [ | |
// Whitespace | |
[/\s+/, 'white'], | |
// String parameters | |
[/"(\\.|[^"\\])*"/, 'string'], | |
[/'(\\.|[^'\\])*'/, 'string'], | |
// Numbers | |
[/\b\d+(\.\d+)?\b/, 'number'], | |
// Variables | |
[/[a-zA-Z_][\w\-]*/, 'variable'], | |
// Punctuation | |
[/[,]/, 'delimiter'], | |
// End of filter args | |
[/\|/, 'operator', '@popall'], | |
[/\%}|\}\}/, { token: '@rematch', next: '@popall' }], | |
], | |
}, | |
}) | |
monacoInstance.languages.setLanguageConfiguration('liquid', { | |
comments: { | |
blockComment: ['{%', '%}'], | |
}, | |
brackets: [ | |
['{%', '%}'], | |
['{{', '}}'], | |
['{', '}'], | |
['[', ']'], | |
['(', ')'], | |
], | |
autoClosingPairs: [ | |
{ open: '{{', close: '}}' }, | |
{ open: '{%', close: '%}' }, | |
{ open: '"', close: '"' }, | |
{ open: "'", close: "'" }, | |
{ open: '[', close: ']' }, | |
{ open: '(', close: ')' }, | |
], | |
surroundingPairs: [ | |
{ open: '{%', close: '%}' }, | |
{ open: '{{', close: '}}' }, | |
{ open: '"', close: '"' }, | |
{ open: "'", close: "'" }, | |
{ open: '[', close: ']' }, | |
{ open: '(', close: ')' }, | |
], | |
folding: { | |
markers: { | |
start: /^\s*{%-?\s*(comment|raw|if|unless|case|for|tablerow|form|paginate|capture|schema|stylesheet|javascript|layout|block)\s*-?%}/, | |
end: /^\s*{%-?\s*end(comment|raw|if|unless|case|for|tablerow|form|paginate|capture|schema|stylesheet|javascript|layout|block)\s*-?%}/, | |
}, | |
}, | |
}) | |
monacoInstance.languages.registerCompletionItemProvider('liquid', { | |
triggerCharacters: ['.', ' ', '{', '%', '|'], | |
provideCompletionItems: (model, position) => { | |
const suggestions = [ | |
...getKeywordSuggestions(monacoInstance, position), | |
...getSchemaSuggestions(monacoInstance, model, position, schemas), | |
] | |
return { suggestions } | |
}, | |
}) | |
monacoInstance.editor.getModels().forEach((model) => { | |
if (model.getLanguageId() === 'liquid') { | |
setupValidation(model) | |
} | |
}) | |
monacoInstance.editor.onDidCreateModel((model) => { | |
if (model.getLanguageId() === 'liquid') { | |
setupValidation(model) | |
} | |
}) | |
function setupValidation(model: monaco.editor.ITextModel) { | |
validateLiquidSyntax(monacoInstance, model) | |
model.onDidChangeContent(() => { | |
validateLiquidSyntax(monacoInstance, model) | |
}) | |
} | |
} | |
function getTypeInfoFromSchema(schema: ZodTypeAny, path: Array<string>): string | null { | |
let currentSchema = schema | |
for (const segment of path) { | |
if (currentSchema instanceof ZodArray) { | |
currentSchema = currentSchema.element | |
} | |
if (/^\d+$/.test(segment)) { | |
// Skip array indices | |
} else if (currentSchema instanceof ZodObject) { | |
const shape = currentSchema.shape | |
if (Object.prototype.hasOwnProperty.call(shape, segment)) { | |
currentSchema = shape[segment] | |
} else { | |
return null | |
} | |
} else { | |
return null | |
} | |
} | |
if (currentSchema instanceof ZodArray) { | |
return 'Array' | |
} else if (currentSchema instanceof ZodObject) { | |
return 'Object' | |
} else if (currentSchema instanceof ZodString) { | |
return 'String' | |
} else if (currentSchema instanceof ZodNumber) { | |
return 'Number' | |
} else if (currentSchema instanceof ZodBoolean) { | |
return 'Boolean' | |
} else { | |
return 'Unknown' | |
} | |
} | |
function getKeywordSuggestions( | |
monacoInstance: typeof monaco, | |
position: monaco.Position, | |
): Array<monaco.languages.CompletionItem> { | |
const keywords: Array<string> = [ | |
'assign', | |
'capture', | |
'endcapture', | |
'increment', | |
'decrement', | |
'if', | |
'endif', | |
'unless', | |
'endunless', | |
'elsif', | |
'else', | |
'for', | |
'endfor', | |
'break', | |
'continue', | |
'limit', | |
'offset', | |
'range', | |
'reversed', | |
'cols', | |
'case', | |
'endcase', | |
'when', | |
'cycle', | |
'tablerow', | |
'endtablerow', | |
'include', | |
'render', | |
'with', | |
'comment', | |
'endcomment', | |
'raw', | |
'endraw', | |
'true', | |
'false', | |
'nil', | |
'and', | |
'or', | |
'not', | |
'in', | |
'contains', | |
'startswith', | |
'endswith', | |
'abs', | |
'append', | |
'at_least', | |
'at_most', | |
'capitalize', | |
'ceil', | |
'compact', | |
'concat', | |
'date', | |
'default', | |
'divided_by', | |
'downcase', | |
'escape', | |
'escape_once', | |
'first', | |
'floor', | |
'join', | |
'last', | |
'lstrip', | |
'map', | |
'minus', | |
'modulo', | |
'newline_to_br', | |
'plus', | |
'prepend', | |
'remove', | |
'remove_first', | |
'replace', | |
'replace_first', | |
'reverse', | |
'round', | |
'rstrip', | |
'size', | |
'slice', | |
'sort', | |
'sort_natural', | |
'split', | |
'strip', | |
'strip_html', | |
'strip_newlines', | |
'times', | |
'truncate', | |
'truncatewords', | |
'uniq', | |
'upcase', | |
'url_decode', | |
'url_encode', | |
] | |
const range = new monacoInstance.Range(position.lineNumber, position.column, position.lineNumber, position.column) | |
return keywords.map((keyword) => ({ | |
label: keyword, | |
kind: monacoInstance.languages.CompletionItemKind.Keyword, | |
insertText: keyword, | |
range, | |
sortText: `2${keyword}`, // Sort keywords after variables and properties | |
})) | |
} | |
function getVariablePathAtPosition(model: monaco.editor.ITextModel, position: monaco.Position): string | null { | |
const lineContent = model.getLineContent(position.lineNumber) | |
const textUntilPosition = lineContent.substring(0, position.column - 1) | |
// Regex to match variable paths (e.g., order.cart[0].product) | |
const variablePathRegex = /[a-zA-Z_][\w\.\[\]]*$/ | |
const match = variablePathRegex.exec(textUntilPosition) | |
if (match) { | |
return match[0] | |
} | |
return null | |
} | |
function getSchemaSuggestions( | |
monacoInstance: typeof monaco, | |
model: monaco.editor.ITextModel, | |
position: monaco.Position, | |
schemas: Record<string, ZodSchema<unknown>>, | |
): Array<monaco.languages.CompletionItem> { | |
const wordUntilPosition = model.getWordUntilPosition(position) | |
const range = new monacoInstance.Range( | |
position.lineNumber, | |
wordUntilPosition.startColumn, | |
position.lineNumber, | |
wordUntilPosition.endColumn, | |
) | |
const variablePath = getVariablePathAtPosition(model, position) | |
if (variablePath) { | |
// Split the path by dots, brackets, and filter out empty strings | |
const pathSegments = variablePath.split(/\.|\[|\]/).filter((segment) => segment !== '') | |
const variableName = pathSegments[0] | |
if (Object.prototype.hasOwnProperty.call(schemas, variableName)) { | |
const schema = schemas[variableName] | |
const pathWithoutVariableName = pathSegments.slice(1) | |
return getSuggestionsFromSchema(monacoInstance, schema, pathWithoutVariableName, range) | |
} | |
} else { | |
// If no variable path, suggest top-level schema variables | |
return Object.keys(schemas).map((variableName) => ({ | |
label: variableName, | |
kind: monacoInstance.languages.CompletionItemKind.Variable, | |
insertText: variableName, | |
sortText: `0${variableName}`, // Sort top-level variables first | |
range, | |
})) | |
} | |
return [] | |
} | |
function getSuggestionsFromSchema( | |
monacoInstance: typeof monaco, | |
schema: ZodTypeAny, | |
path: Array<string>, | |
range: monaco.Range, | |
): Array<monaco.languages.CompletionItem> { | |
let currentSchema = schema | |
for (const segment of path) { | |
if (currentSchema instanceof ZodArray) { | |
currentSchema = currentSchema.element | |
} | |
if (/^\d+$/.test(segment)) { | |
// Skip array indices | |
} else if (currentSchema instanceof ZodObject) { | |
const shape = currentSchema.shape | |
if (Object.prototype.hasOwnProperty.call(shape, segment)) { | |
currentSchema = shape[segment] | |
} else { | |
return [] | |
} | |
} else { | |
return [] | |
} | |
} | |
if (currentSchema instanceof ZodArray) { | |
currentSchema = currentSchema.element | |
} | |
if (currentSchema instanceof ZodObject) { | |
const suggestions: Array<monaco.languages.CompletionItem> = [] | |
const shape = currentSchema.shape | |
for (const key in shape) { | |
if (Object.prototype.hasOwnProperty.call(shape, key)) { | |
const value = shape[key] | |
let kind = monacoInstance.languages.CompletionItemKind.Field | |
let detail = '' | |
let documentation = '' | |
if (value instanceof ZodArray) { | |
kind = monacoInstance.languages.CompletionItemKind.Variable | |
detail = 'Array' | |
documentation = 'Type: Array' | |
} else if (value instanceof ZodObject) { | |
kind = monacoInstance.languages.CompletionItemKind.Class | |
detail = 'Object' | |
documentation = 'Type: Object' | |
} else if (value instanceof ZodString) { | |
kind = monacoInstance.languages.CompletionItemKind.Property | |
detail = 'String' | |
documentation = 'Type: String' | |
} else if (value instanceof ZodNumber) { | |
kind = monacoInstance.languages.CompletionItemKind.Property | |
detail = 'Number' | |
documentation = 'Type: Number' | |
} else if (value instanceof ZodBoolean) { | |
kind = monacoInstance.languages.CompletionItemKind.Property | |
detail = 'Boolean' | |
documentation = 'Type: Boolean' | |
} else { | |
kind = monacoInstance.languages.CompletionItemKind.Property | |
detail = 'Unknown' | |
documentation = 'Type: Unknown' | |
} | |
suggestions.push({ | |
label: key, | |
kind, | |
insertText: key, | |
detail, | |
documentation, | |
range, | |
sortText: `1${key}`, // Sort properties after variables | |
}) | |
} | |
} | |
return suggestions | |
} | |
return [] | |
} | |
function validateLiquidSyntax(monacoInstance: typeof monaco, model: monaco.editor.ITextModel) { | |
const text = model.getValue() | |
const lines = text.split(/\r?\n/) | |
const markers: Array<monaco.editor.IMarkerData> = [] | |
const stack: Array<{ tag: string; lineNumber: number; column: number }> = [] | |
const tagRegex = /{%-?\s*(\w+)(?:\s[^%]*)?-?%}/g | |
const endTagRegex = /{%-?\s*end(\w+)\s*-?%}/g | |
const outputStartRegex = /\{\{-?/g | |
const outputEndRegex = /-?\}\}/g | |
lines.forEach((lineContent, index) => { | |
const lineNumber = index + 1 | |
tagRegex.lastIndex = 0 | |
endTagRegex.lastIndex = 0 | |
outputStartRegex.lastIndex = 0 | |
outputEndRegex.lastIndex = 0 | |
let match: RegExpExecArray | null | |
const outputMatches: Array<{ type: 'start' | 'end'; index: number }> = [] | |
match = outputStartRegex.exec(lineContent) | |
while (match !== null) { | |
outputMatches.push({ type: 'start', index: match.index }) | |
match = outputStartRegex.exec(lineContent) | |
} | |
match = outputEndRegex.exec(lineContent) | |
while (match !== null) { | |
outputMatches.push({ type: 'end', index: match.index }) | |
match = outputEndRegex.exec(lineContent) | |
} | |
outputMatches.sort((a, b) => a.index - b.index) | |
outputMatches.forEach((outputMatch) => { | |
if (outputMatch.type === 'start') { | |
stack.push({ tag: 'output', lineNumber, column: outputMatch.index + 1 }) | |
} else { | |
// outputMatch.type === 'end' | |
let last: (typeof stack)[number] | undefined | |
while (stack.length > 0) { | |
last = stack.pop() | |
if (last?.tag === 'output') { | |
break | |
} | |
} | |
if (!last || last.tag !== 'output') { | |
// Unmatched closing output tag | |
markers.push({ | |
severity: monacoInstance.MarkerSeverity.Error, | |
message: `Unmatched closing '}}'`, | |
startLineNumber: lineNumber, | |
startColumn: outputMatch.index + 1, | |
endLineNumber: lineNumber, | |
endColumn: outputMatch.index + 3, | |
}) | |
} | |
} | |
}) | |
match = tagRegex.exec(lineContent) | |
while (match !== null) { | |
const tag = match[1] | |
const position = match.index | |
// Exclude end tags from opening tag regex matches | |
if (!tag.startsWith('end') && isBlockTag(tag)) { | |
stack.push({ tag, lineNumber, column: position + 1 }) | |
} | |
match = tagRegex.exec(lineContent) | |
} | |
match = endTagRegex.exec(lineContent) | |
while (match !== null) { | |
const endTag = match[1] | |
const position = match.index | |
if (stack.length === 0) { | |
// Unmatched end tag | |
markers.push({ | |
severity: monacoInstance.MarkerSeverity.Error, | |
message: `Unmatched end tag 'end${endTag}'`, | |
startLineNumber: lineNumber, | |
startColumn: position + 1, | |
endLineNumber: lineNumber, | |
endColumn: position + match[0].length + 1, | |
}) | |
} else { | |
const last = stack.pop() | |
if (last?.tag !== endTag) { | |
// Mismatched end tag | |
markers.push({ | |
severity: monacoInstance.MarkerSeverity.Error, | |
message: `Expected 'end${last?.tag}' but found 'end${endTag}'`, | |
startLineNumber: lineNumber, | |
startColumn: position + 1, | |
endLineNumber: lineNumber, | |
endColumn: position + match[0].length + 1, | |
}) | |
} | |
} | |
match = endTagRegex.exec(lineContent) | |
} | |
}) | |
while (stack.length > 0) { | |
const unmatchedTag = stack.pop() | |
let message = '' | |
if (unmatchedTag?.tag === 'output') { | |
message = `Unclosed output tag '{{'` | |
} else { | |
message = `Unclosed tag '${unmatchedTag?.tag}'` | |
} | |
markers.push({ | |
severity: monacoInstance.MarkerSeverity.Error, | |
message, | |
startLineNumber: unmatchedTag?.lineNumber || 1, | |
startColumn: unmatchedTag?.column || 1, | |
endLineNumber: unmatchedTag?.lineNumber || 1, | |
endColumn: unmatchedTag?.column || 1, | |
}) | |
} | |
monacoInstance.editor.setModelMarkers(model, 'liquid', markers) | |
} | |
const blockTags: Array<string> = [ | |
'if', | |
'unless', | |
'case', | |
'for', | |
'tablerow', | |
'comment', | |
'raw', | |
'capture', | |
'form', | |
'paginate', | |
'layout', | |
'block', | |
'schema', | |
'stylesheet', | |
'javascript', | |
] | |
function isBlockTag(tag: string): boolean { | |
return blockTags.includes(tag) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment