Браузерные редакторы
Учим Monaco читать Liquid-шаблоны: главное — понять, какой язык под курсором
Как собственный токенизатор для HTML с Liquid и вставками JavaScript и CSS научил подсказки следовать за курсором и почему редактор стал версионируемым пакетом с API из трёх функций.
На этой странице
Обычный кусок шаблона витрины:
<a class="product-card {% if product.available %}is-available{% endif %}"
href="{{ product.url }}">{{ product.title | escape }}</a>
<script type="application/ld+json">
{ "name": {{ product.title | json }} }
</script>
Здесь HTML, Liquid в разметке, Liquid внутри значения атрибута и блок script, в котором лежит JSON, а не JavaScript. Откройте это в штатном HTML-режиме Monaco: Liquid окажется просто текстом, подсветка внутри атрибута поплывёт, а что в скрипте JSON, редактор не узнает.
Встроить Monaco в страницу — самое простое. Это движок редактора VS Code: модели, команды, API языковых провайдеров. Но он понятия не имеет, что шаблон витрины — это несколько языков в одном файле. Расширения VS Code в браузерный Monaco сами не подключаются, так что всё пришлось описывать средствами самого Monaco.
В inSales, e-commerce-платформе с 60 000+ магазинов, партнёры, сотрудники поддержки и технически подкованные владельцы магазинов правят шаблоны прямо в админке. Старая интеграция CodeMirror давала им простое поле без смешанной подсветки, контекстных подсказок и Emmet. Я сделал кастомную сборку Monaco, которая её заменила, и был её основным разработчиком с июля 2022 по октябрь 2025 года.
Токенизатор — конечный автомат с памятью
Подсветку в Monaco делает Monarch, декларативный токенизатор: набор состояний, в каждом есть правила, которые находят текст и переводят в другое состояние. Я зарегистрировал отдельный язык для HTML с Liquid и написал для него токенизатор. Он различает HTML-теги и атрибуты, вывод Liquid {{ … }}, теги {% … %}, комментарии и блоки raw, переменные, операторы, фильтры и теги inSales.
Самым хитрым был Liquid внутри значения атрибута. Значение может целиком состоять из Liquid-выражения, а может смешивать обычный текст и несколько вставок. После закрывающих }} редактор должен вернуться ровно туда, откуда пришёл: внутрь значения атрибута в двойных кавычках. Не «куда-то в HTML» и не продолжать подсвечивать остаток строки как Liquid.
Стек состояний Monarch умеет это сам, если им пользоваться. Вход в Liquid кладёт состояние в стек, выход снимает его и возвращает туда, откуда был вход:
// Упрощено: закрывающие теги, пробелы и часть состояний опущены
monaco.languages.setMonarchTokensProvider('liquid', {
tokenizer: {
root: [
[/\{\{/, { token: 'delimiter.liquid', next: '@liquidOutput' }],
[/\{%/, { token: 'delimiter.liquid', next: '@liquidTag' }],
[/<[\w-]+/, { token: 'tag', next: '@tag' }],
],
tag: [
[/[\w-]+/, 'attribute.name'],
[/="/, { token: 'attribute.value', next: '@attributeValue' }],
[/\/?>/, { token: 'tag', next: '@pop' }],
],
attributeValue: [
[/\{\{/, { token: 'delimiter.liquid', next: '@liquidOutput' }],
[/\{%/, { token: 'delimiter.liquid', next: '@liquidTag' }],
[/"/, { token: 'attribute.value', next: '@pop' }],
[/[^"{]+|\{/, 'attribute.value'],
],
liquidOutput: [
[/\}\}/, { token: 'delimiter.liquid', next: '@pop' }],
[/\|\s*[\w-]+/, 'predefined.liquid'],
[/[\w.]+/, 'variable.liquid'],
],
// liquidTag, комментарии, raw, script и style устроены так же
},
})
Одно и то же состояние @liquidOutput работает и в теле документа, и в значении атрибута, а @pop решает, куда возвращаться. Языковая конфигурация вокруг добавляет пары скобок, автозакрытие и отступы после открывающего тега. В итоге смешанный файл ведёт себя как один язык, а не как набор раскрашенных кусков.
Реальный продукт находит то, чего нет на демо-странице. Один вариант регулярного выражения в токенизаторе полностью ломал редактор в Safari, и в коде до сих пор есть пометка, почему от него отказались.
Script и style — другие языки
Внутри <script> нужны подсказки JavaScript, а не HTML-атрибуты и Liquid-сниппеты. Внутри <style> нужны CSS и Emmet. Токенизатор переключается на вложенный язык при входе в такой тег и возвращается к HTML после закрывающего.
Для скриптов важен атрибут type. Обычному JavaScript, application/json и application/ld+json нужны разные режимы, а неожиданное значение не должно заставлять Monaco грузить языковой сервис, которого не существует. Пока я это не обработал, такие блоки сыпали ошибками. Решение — явная нормализация с безопасным значением по умолчанию:
const scriptLanguageByType: Record<string, string> = {
'': 'javascript',
'text/javascript': 'javascript',
'application/json': 'json',
'application/ld+json': 'json',
}
function scriptLanguage(type = '') {
return scriptLanguageByType[type.trim().toLowerCase()] ?? 'javascript'
}
Спрашивать модель, а не текст
Подсветка — только половина работы. Автодополнение должно было соблюдать те же границы: Liquid-сниппеты не вытесняют HTML-подсказки, а HTML-атрибуты не появляются, пока человек пишет JavaScript.
В первой версии я определял контекст, просматривая текст перед курсором в поисках открывающего и закрывающего тега:
// Было: угадываем контекст по тексту перед курсором
const textBefore = model.getValueInRange(new monaco.Range(1, 1, position.lineNumber, position.column))
const isInsideScript = textBefore.lastIndexOf('<script') > textBefore.lastIndexOf('</script')
В типичном случае это работало, но было неверно по сути. Такая проверка не отличала JSON-скрипт от JavaScript, а каждый пограничный случай, который уже учёл токенизатор, пришлось бы повторять регулярками. В сентябре 2024 года я заменил её вопросом, на который модель уже знает ответ, потому что токенизатор эту работу проделал:
monaco.languages.registerCompletionItemProvider('liquid', {
provideCompletionItems(model, position) {
const word = model.getWordUntilPosition(position)
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)
const languageId = model.getLanguageIdAtPosition(position.lineNumber, position.column)
return { suggestions: snippetsFor(languageId, range) }
},
})
Теперь на вопрос «что это за язык» отвечает один источник. Улучшается токенизатор — вместе с ним улучшаются подсказки. За snippetsFor стоят наборы сниппетов для Liquid, JavaScript и CSS: всего 440 записей, часть собрана из существующих наборов и адаптирована под сборку. Emmet подключён отдельно для HTML и SCSS, чтобы не спорить с Liquid-подсказками.
Поставлять редактор, а не компонент
Редактор появился внутри одного Vue-компонента — редактора кода виджета, где в одном окне шесть файлов. Разметка с Liquid, SCSS, JavaScript и три JSON-файла: переводы, схема формы настроек и значения по умолчанию. У трёх JSON-вкладок один язык, но совершенно разный смысл, поэтому компонент выбирает язык по типу файла, а не по вкладке.
Когда редактор понадобился на других экранах, копировать компонент было нельзя. Я вынес сборку в отдельный репозиторий. Webpack с monaco-editor-webpack-plugin собирает один продакшен-бандл с семью языковыми идентификаторами Monaco и отдельными файлами веб-воркеров, встраивает шрифты, чтобы их не теряли пути к ассетам, и минифицирует через Terser. Rails Engine и Rake-задача доставляют бандл и воркеры в asset pipeline как версионируемый внутренний gem, а CI собирает бандл, gem и публикует его во внутренний реестр.
Больше всего возни было с воркерами. Webpack знает исходные имена файлов, а Rails asset pipeline публикует их с хешем в URL. Редактор открывался, но его языковые воркеры не могли запуститься. Маршрутизацию воркеров написал коллега: она находит опубликованные пути и подставляет их в механизм, которым Monaco ищет воркеры.
Контракт с продуктом — три функции в window:
window.getMonacoInstance = () => monaco
window.monacoCreate = (container, { language, value }) =>
monaco.editor.create(container, { ...sharedEditorOptions, language, value })
window.monacoLanguageRegisterApi = { registerLiquid, registerJavaScript, registerScss }
function registerLiquid() {
const isRegistered = monaco.languages.getLanguages().some(({ id }) => id === 'liquid')
if (isRegistered) return
// один раз регистрируем язык, конфигурацию, токенизатор и провайдеры
}
Для современного изолированного пакета глобальный API я бы не выбрал. Но для существующего Rails-приложения с asset pipeline это был правильный контракт: сборка подключается как ассет, и ей пользуются и старые Rails-страницы, и Vue-экраны. Защита от повторной регистрации нужна на страницах, где редактор создаётся несколько раз: иначе каждая регистрация добавляла бы ещё одну копию провайдеров и подсказок.
Самое полезное здесь — граница. Пакет отвечает за редактирование: языки, подсказки, Emmet, команды, тему и общие настройки (перенос на 120 символах, автоподстройка под контейнер, сворачивание блоков, отступ в два пробела). Экран, который его встраивает, отвечает за свои данные, сохранение и предпросмотр.
Пять интерфейсов на одной сборке
Из-за этой границы одна сборка оказалась в пяти интерфейсах: код виджета, полный редактор шаблона витрины, шаблоны писем по заказам, сообщения о брошенных корзинах и уведомления о статусе заказа для email, SMS и мессенджеров. В письмах о брошенных корзинах мы заменили WYSIWYG-редактор, который перекодировал HTML-теги и мог повредить Liquid-логику внутри. В коммуникационных разделах рядом с редактором стоит предпросмотр на данных реального заказа или корзины.
Другие разработчики дорабатывали пакет в одном месте, и изменения получали все экраны: коллеги обновили цвета темы и добавили форматирование Liquid через Prettier. Подробнее — в кейсе о редакторе кода.
Что я бы посоветовал команде, которая делает свой язык в Monaco
- Всё знание о контексте держите в токенизаторе и спрашивайте модель.
getLanguageIdAtPositionнадёжнее любой регулярки по сырому тексту. - Проектируйте токенизатор вокруг возврата из состояний. Войти во вложенный язык легко, большинство ошибок — в возврате в нужное место.
- Явно сопоставляйте каждое значение
typeво вложенных блоках с языком и задайте безопасное значение для неизвестных. - Рано решите, за что отвечает пакет, а за что — экран, который его встраивает. Переиспользуемым редактор сделало именно это решение, а не сам Monaco.