На этой странице

Обычный кусок шаблона витрины:

<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.
  • Monaco
  • Liquid
  • Tokenizer
  • Web workers
  • Packaging
Александр Ануфриев
Александр Ануфриев

Senior frontend-разработчик