monaco-liquid
Version:
Liquid templating language support for Monaco Editor with Zod schema validation
541 lines (540 loc) • 19.7 kB
JavaScript
import { ZodArray, ZodBoolean, ZodDate, ZodDefault, ZodEnum, ZodLiteral, ZodNullable, ZodNumber, ZodObject, ZodOptional, ZodString, ZodUnion, } from 'zod';
export const registerLiquidLanguage = (monacoInstance) => {
monacoInstance.languages.register({ id: 'liquid' });
monacoInstance.languages.registerHoverProvider('liquid', {
provideHover: (model, position) => {
if (!model.schemas)
return null;
const variablePath = getVariablePathAtPosition(model, position);
if (variablePath) {
const pathSegments = variablePath.split(/\.|\[|\]/).filter((segment) => segment !== '');
const variableName = pathSegments[0];
if (Object.prototype.hasOwnProperty.call(model.schemas, variableName)) {
const schema = model.schemas[variableName];
const pathWithoutVariableName = pathSegments.slice(1);
const typeInfo = getTypeInfoFromSchema(schema, pathWithoutVariableName);
if (typeInfo) {
const word = model.getWordAtPosition(position);
return {
range: word
? new monacoInstance.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)
: undefined,
contents: [{ value: `**Type:** \`${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: [
[/{%\s*comment\s*%}/, { token: 'comment.block.start', next: '@commentBlock' }],
[/{%\s*raw\s*%}/, { token: 'string.raw.start', next: '@rawBlock' }],
[/\{\{/, { token: 'delimiter.output', next: '@output' }],
[/{%\s*(if|unless|elsif|else|case|when|for|tablerow|end\w+)\b/, { token: 'keyword.control', next: '@tag' }],
[/{%\s*(assign|capture|increment|decrement|include|render|cycle|break|continue)\b/, { token: 'keyword', next: '@tag' }],
[/{%/, { token: 'delimiter.tag', next: '@tag' }],
[/[^<{]+/, ''],
[/<\/?[\w\-]+.*?>/, 'tag'],
[/[<{]/, ''],
],
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: [
[/\s+/, 'white'],
[/==|!=|<=|>=|<|>|and|or|contains/, 'operator'],
[/\b\d+(\.\d+)?\b/, 'number'],
[/"(\\.|[^"\\])*"/, 'string'],
[/'(\\.|[^'\\])*'/, 'string'],
[/\b(true|false|nil)\b/, 'constant.language'],
[
/\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',
],
[/\|/, { token: 'operator', next: '@filter' }],
[/[a-zA-Z_][\w\-]*/, 'variable'],
[/[\[\]().,]/, 'delimiter'],
[/./, ''],
],
filter: [
[/\s*(\w+)/, 'function'],
[/:/, 'operator', '@filterargs'],
[/\|/, 'operator', '@pop'],
[/\%}|\}\}/, { token: '@rematch', next: '@pop' }],
],
filterargs: [
[/\s+/, 'white'],
[/"(\\.|[^"\\])*"/, 'string'],
[/'(\\.|[^'\\])*'/, 'string'],
[/\b\d+(\.\d+)?\b/, 'number'],
[/[a-zA-Z_][\w\-]*/, 'variable'],
[/[,]/, 'delimiter'],
[/\|/, 'operator', '@popall'],
[/\%}|\}\}/, { token: '@rematch', next: '@popall' }],
],
},
});
monacoInstance.languages.setLanguageConfiguration('liquid', {
comments: {
blockComment: ['{% comment %}', '{% endcomment %}'],
},
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*(comment|raw|if|unless|case|for|tablerow|form|paginate|capture|schema|stylesheet|javascript|layout|block|liquid)\b/,
end: /{%-?\s*end(comment|raw|if|unless|case|for|tablerow|form|paginate|capture|schema|stylesheet|javascript|layout|block|liquid)\s*-?%}/,
},
},
});
monacoInstance.languages.registerCompletionItemProvider('liquid', {
triggerCharacters: ['.', ' ', '{', '%', '|'],
provideCompletionItems: (model, position) => {
const suggestions = [
...getKeywordSuggestions(monacoInstance, position),
...(model.schemas ? getSchemaSuggestions(monacoInstance, model, position, model.schemas) : []),
];
return { suggestions };
},
});
};
function unwrapSchema(schema) {
if (schema instanceof ZodOptional || schema instanceof ZodNullable || schema instanceof ZodDefault) {
return unwrapSchema(schema._zod.def.innerType);
}
return schema;
}
function navigateSchema(schema, path) {
let currentSchema = unwrapSchema(schema);
for (const segment of path) {
currentSchema = unwrapSchema(currentSchema);
if (currentSchema instanceof ZodArray) {
currentSchema = unwrapSchema(currentSchema.element);
}
if (/^\d+$/.test(segment)) {
}
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;
}
}
return unwrapSchema(currentSchema);
}
function getTypeString(schema) {
const unwrapped = unwrapSchema(schema);
if (unwrapped instanceof ZodArray) {
const elementType = getTypeString(unwrapped.element);
return `Array<${elementType}>`;
}
else if (unwrapped instanceof ZodObject) {
return 'Object';
}
else if (unwrapped instanceof ZodString) {
return 'String';
}
else if (unwrapped instanceof ZodNumber) {
return 'Number';
}
else if (unwrapped instanceof ZodBoolean) {
return 'Boolean';
}
else if (unwrapped instanceof ZodDate) {
return 'Date';
}
else if (unwrapped instanceof ZodLiteral) {
const values = unwrapped._zod.def.values;
const value = values[0];
return typeof value === 'string' ? `"${value}"` : String(value);
}
else if (unwrapped instanceof ZodEnum) {
const entries = unwrapped._zod.def.entries;
const values = Object.values(entries);
return values.map((v) => `"${v}"`).join(' | ');
}
else if (unwrapped instanceof ZodUnion) {
const options = unwrapped._zod.def.options;
return options.map((opt) => getTypeString(opt)).join(' | ');
}
else {
return 'Unknown';
}
}
function getTypeInfoFromSchema(schema, path) {
const navigated = navigateSchema(schema, path);
if (navigated === null)
return null;
return getTypeString(navigated);
}
export const setModelLiquidValidation = (monacoInstance, model, schemas, options = {}) => {
const { debounceMs = 300 } = options;
model.schemas = schemas;
validateLiquidSyntax(monacoInstance, model);
let validationTimeout;
const disposable = model.onDidChangeContent(() => {
if (validationTimeout !== undefined) {
window.clearTimeout(validationTimeout);
}
validationTimeout = window.setTimeout(() => {
validateLiquidSyntax(monacoInstance, model);
validationTimeout = undefined;
}, debounceMs);
});
return {
dispose: () => {
if (validationTimeout !== undefined) {
window.clearTimeout(validationTimeout);
}
disposable.dispose();
monacoInstance.editor.setModelMarkers(model, 'liquid', []);
},
};
};
function getKeywordSuggestions(monacoInstance, position) {
const keywords = [
'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}`,
}));
}
function getVariablePathAtPosition(model, position) {
const lineContent = model.getLineContent(position.lineNumber);
const textUntilPosition = lineContent.substring(0, position.column - 1);
const variablePathRegex = /[a-zA-Z_][\w\-.\[\]]*$/;
const match = variablePathRegex.exec(textUntilPosition);
if (match) {
return match[0];
}
return null;
}
function getSchemaSuggestions(monacoInstance, model, position, schemas) {
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) {
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 {
return Object.keys(schemas).map((variableName) => ({
label: variableName,
kind: monacoInstance.languages.CompletionItemKind.Variable,
insertText: variableName,
sortText: `0${variableName}`,
range,
}));
}
return [];
}
function getCompletionKindForSchema(monacoInstance, schema) {
const unwrapped = unwrapSchema(schema);
if (unwrapped instanceof ZodArray) {
return monacoInstance.languages.CompletionItemKind.Variable;
}
else if (unwrapped instanceof ZodObject) {
return monacoInstance.languages.CompletionItemKind.Class;
}
else {
return monacoInstance.languages.CompletionItemKind.Property;
}
}
function getSuggestionsFromSchema(monacoInstance, schema, path, range) {
const navigated = navigateSchema(schema, path);
if (navigated === null)
return [];
let currentSchema = unwrapSchema(navigated);
if (currentSchema instanceof ZodArray) {
currentSchema = unwrapSchema(currentSchema.element);
}
if (currentSchema instanceof ZodObject) {
const suggestions = [];
const shape = currentSchema.shape;
for (const key in shape) {
if (Object.prototype.hasOwnProperty.call(shape, key)) {
const value = shape[key];
const typeString = getTypeString(value);
suggestions.push({
label: key,
kind: getCompletionKindForSchema(monacoInstance, value),
insertText: key,
detail: typeString,
documentation: { value: `**Type:** \`${typeString}\`` },
range,
sortText: `1${key}`,
});
}
}
return suggestions;
}
return [];
}
function validateLiquidSyntax(monacoInstance, model) {
const text = model.getValue();
const lines = text.split(/\r?\n/);
const markers = [];
const stack = [];
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;
const outputMatches = [];
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, length: 2 });
}
else {
const outputIndex = stack.findLastIndex((item) => item.tag === 'output');
if (outputIndex === -1) {
markers.push({
severity: monacoInstance.MarkerSeverity.Error,
message: `Unmatched closing '}}'`,
startLineNumber: lineNumber,
startColumn: outputMatch.index + 1,
endLineNumber: lineNumber,
endColumn: outputMatch.index + 3,
});
}
else {
stack.splice(outputIndex, 1);
}
}
});
match = tagRegex.exec(lineContent);
while (match !== null) {
const tag = match[1];
const position = match.index;
if (!tag.startsWith('end') && isBlockTag(tag)) {
stack.push({ tag, lineNumber, column: position + 1, length: match[0].length });
}
match = tagRegex.exec(lineContent);
}
match = endTagRegex.exec(lineContent);
while (match !== null) {
const endTag = match[1];
const position = match.index;
if (stack.length === 0) {
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) {
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();
const message = unmatchedTag.tag === 'output' ? `Unclosed output tag '{{'` : `Unclosed tag '${unmatchedTag.tag}'`;
markers.push({
severity: monacoInstance.MarkerSeverity.Error,
message,
startLineNumber: unmatchedTag.lineNumber,
startColumn: unmatchedTag.column,
endLineNumber: unmatchedTag.lineNumber,
endColumn: unmatchedTag.column + unmatchedTag.length,
});
}
monacoInstance.editor.setModelMarkers(model, 'liquid', markers);
}
const blockTags = [
'if',
'unless',
'case',
'for',
'tablerow',
'comment',
'raw',
'capture',
'form',
'paginate',
'layout',
'block',
'schema',
'stylesheet',
'javascript',
'liquid',
];
function isBlockTag(tag) {
return blockTags.includes(tag);
}