[{"data":1,"prerenderedAt":268},["ShallowReactive",2],{"post-ru-monaco-liquid-tokenizer":3},{"id":4,"title":5,"body":6,"category":247,"cover":248,"coverAlt":249,"date":250,"description":251,"extension":252,"locale":253,"meta":254,"navigation":255,"path":256,"published":255,"readingMinutes":257,"seo":258,"slug":259,"stem":260,"tags":261,"__hash__":267},"posts\u002Fru\u002Fposts\u002Fmonaco-liquid-tokenizer.md","Учим Monaco читать Liquid-шаблоны: главное — понять, какой язык под курсором",{"type":7,"value":8,"toc":238},"minimark",[9,13,24,31,34,37,42,57,64,67,75,86,89,93,104,119,125,129,132,135,141,144,150,157,161,164,171,174,181,189,192,195,199,202,211,215],[10,11,12],"p",{},"Обычный кусок шаблона витрины:",[14,15,21],"pre",{"className":16,"code":18,"language":19,"meta":20},[17],"language-liquid","\u003Ca class=\"product-card {% if product.available %}is-available{% endif %}\"\n   href=\"{{ product.url }}\">{{ product.title | escape }}\u003C\u002Fa>\n\n\u003Cscript type=\"application\u002Fld+json\">\n  { \"name\": {{ product.title | json }} }\n\u003C\u002Fscript>\n","liquid","",[22,23,18],"code",{"__ignoreMap":20},[10,25,26,27,30],{},"Здесь HTML, Liquid в разметке, Liquid внутри значения атрибута и блок ",[22,28,29],{},"script",", в котором лежит JSON, а не JavaScript. Откройте это в штатном HTML-режиме Monaco: Liquid окажется просто текстом, подсветка внутри атрибута поплывёт, а что в скрипте JSON, редактор не узнает.",[10,32,33],{},"Встроить Monaco в страницу — самое простое. Это движок редактора VS Code: модели, команды, API языковых провайдеров. Но он понятия не имеет, что шаблон витрины — это несколько языков в одном файле. Расширения VS Code в браузерный Monaco сами не подключаются, так что всё пришлось описывать средствами самого Monaco.",[10,35,36],{},"В inSales, e-commerce-платформе с 60 000+ магазинов, партнёры, сотрудники поддержки и технически подкованные владельцы магазинов правят шаблоны прямо в админке. Старая интеграция CodeMirror давала им простое поле без смешанной подсветки, контекстных подсказок и Emmet. Я сделал кастомную сборку Monaco, которая её заменила, и был её основным разработчиком с июля 2022 по октябрь 2025 года.",[38,39,41],"h2",{"id":40},"токенизатор-конечный-автомат-с-памятью","Токенизатор — конечный автомат с памятью",[10,43,44,45,48,49,52,53,56],{},"Подсветку в Monaco делает Monarch, декларативный токенизатор: набор состояний, в каждом есть правила, которые находят текст и переводят в другое состояние. Я зарегистрировал отдельный язык для HTML с Liquid и написал для него токенизатор. Он различает HTML-теги и атрибуты, вывод Liquid ",[22,46,47],{},"{{ … }}",", теги ",[22,50,51],{},"{% … %}",", комментарии и блоки ",[22,54,55],{},"raw",", переменные, операторы, фильтры и теги inSales.",[10,58,59,60,63],{},"Самым хитрым был Liquid внутри значения атрибута. Значение может целиком состоять из Liquid-выражения, а может смешивать обычный текст и несколько вставок. После закрывающих ",[22,61,62],{},"}}"," редактор должен вернуться ровно туда, откуда пришёл: внутрь значения атрибута в двойных кавычках. Не «куда-то в HTML» и не продолжать подсвечивать остаток строки как Liquid.",[10,65,66],{},"Стек состояний Monarch умеет это сам, если им пользоваться. Вход в Liquid кладёт состояние в стек, выход снимает его и возвращает туда, откуда был вход:",[14,68,73],{"className":69,"code":71,"language":72,"meta":20},[70],"language-ts","\u002F\u002F Упрощено: закрывающие теги, пробелы и часть состояний опущены\nmonaco.languages.setMonarchTokensProvider('liquid', {\n  tokenizer: {\n    root: [\n      [\u002F\\{\\{\u002F, { token: 'delimiter.liquid', next: '@liquidOutput' }],\n      [\u002F\\{%\u002F, { token: 'delimiter.liquid', next: '@liquidTag' }],\n      [\u002F\u003C[\\w-]+\u002F, { token: 'tag', next: '@tag' }],\n    ],\n    tag: [\n      [\u002F[\\w-]+\u002F, 'attribute.name'],\n      [\u002F=\"\u002F, { token: 'attribute.value', next: '@attributeValue' }],\n      [\u002F\\\u002F?>\u002F, { token: 'tag', next: '@pop' }],\n    ],\n    attributeValue: [\n      [\u002F\\{\\{\u002F, { token: 'delimiter.liquid', next: '@liquidOutput' }],\n      [\u002F\\{%\u002F, { token: 'delimiter.liquid', next: '@liquidTag' }],\n      [\u002F\"\u002F, { token: 'attribute.value', next: '@pop' }],\n      [\u002F[^\"{]+|\\{\u002F, 'attribute.value'],\n    ],\n    liquidOutput: [\n      [\u002F\\}\\}\u002F, { token: 'delimiter.liquid', next: '@pop' }],\n      [\u002F\\|\\s*[\\w-]+\u002F, 'predefined.liquid'],\n      [\u002F[\\w.]+\u002F, 'variable.liquid'],\n    ],\n    \u002F\u002F liquidTag, комментарии, raw, script и style устроены так же\n  },\n})\n","ts",[22,74,71],{"__ignoreMap":20},[10,76,77,78,81,82,85],{},"Одно и то же состояние ",[22,79,80],{},"@liquidOutput"," работает и в теле документа, и в значении атрибута, а ",[22,83,84],{},"@pop"," решает, куда возвращаться. Языковая конфигурация вокруг добавляет пары скобок, автозакрытие и отступы после открывающего тега. В итоге смешанный файл ведёт себя как один язык, а не как набор раскрашенных кусков.",[10,87,88],{},"Реальный продукт находит то, чего нет на демо-странице. Один вариант регулярного выражения в токенизаторе полностью ломал редактор в Safari, и в коде до сих пор есть пометка, почему от него отказались.",[38,90,92],{"id":91},"script-и-style-другие-языки","Script и style — другие языки",[10,94,95,96,99,100,103],{},"Внутри ",[22,97,98],{},"\u003Cscript>"," нужны подсказки JavaScript, а не HTML-атрибуты и Liquid-сниппеты. Внутри ",[22,101,102],{},"\u003Cstyle>"," нужны CSS и Emmet. Токенизатор переключается на вложенный язык при входе в такой тег и возвращается к HTML после закрывающего.",[10,105,106,107,110,111,114,115,118],{},"Для скриптов важен атрибут ",[22,108,109],{},"type",". Обычному JavaScript, ",[22,112,113],{},"application\u002Fjson"," и ",[22,116,117],{},"application\u002Fld+json"," нужны разные режимы, а неожиданное значение не должно заставлять Monaco грузить языковой сервис, которого не существует. Пока я это не обработал, такие блоки сыпали ошибками. Решение — явная нормализация с безопасным значением по умолчанию:",[14,120,123],{"className":121,"code":122,"language":72,"meta":20},[70],"const scriptLanguageByType: Record\u003Cstring, string> = {\n  '': 'javascript',\n  'text\u002Fjavascript': 'javascript',\n  'application\u002Fjson': 'json',\n  'application\u002Fld+json': 'json',\n}\n\nfunction scriptLanguage(type = '') {\n  return scriptLanguageByType[type.trim().toLowerCase()] ?? 'javascript'\n}\n",[22,124,122],{"__ignoreMap":20},[38,126,128],{"id":127},"спрашивать-модель-а-не-текст","Спрашивать модель, а не текст",[10,130,131],{},"Подсветка — только половина работы. Автодополнение должно было соблюдать те же границы: Liquid-сниппеты не вытесняют HTML-подсказки, а HTML-атрибуты не появляются, пока человек пишет JavaScript.",[10,133,134],{},"В первой версии я определял контекст, просматривая текст перед курсором в поисках открывающего и закрывающего тега:",[14,136,139],{"className":137,"code":138,"language":72,"meta":20},[70],"\u002F\u002F Было: угадываем контекст по тексту перед курсором\nconst textBefore = model.getValueInRange(new monaco.Range(1, 1, position.lineNumber, position.column))\nconst isInsideScript = textBefore.lastIndexOf('\u003Cscript') > textBefore.lastIndexOf('\u003C\u002Fscript')\n",[22,140,138],{"__ignoreMap":20},[10,142,143],{},"В типичном случае это работало, но было неверно по сути. Такая проверка не отличала JSON-скрипт от JavaScript, а каждый пограничный случай, который уже учёл токенизатор, пришлось бы повторять регулярками. В сентябре 2024 года я заменил её вопросом, на который модель уже знает ответ, потому что токенизатор эту работу проделал:",[14,145,148],{"className":146,"code":147,"language":72,"meta":20},[70],"monaco.languages.registerCompletionItemProvider('liquid', {\n  provideCompletionItems(model, position) {\n    const word = model.getWordUntilPosition(position)\n    const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)\n    const languageId = model.getLanguageIdAtPosition(position.lineNumber, position.column)\n\n    return { suggestions: snippetsFor(languageId, range) }\n  },\n})\n",[22,149,147],{"__ignoreMap":20},[10,151,152,153,156],{},"Теперь на вопрос «что это за язык» отвечает один источник. Улучшается токенизатор — вместе с ним улучшаются подсказки. За ",[22,154,155],{},"snippetsFor"," стоят наборы сниппетов для Liquid, JavaScript и CSS: всего 440 записей, часть собрана из существующих наборов и адаптирована под сборку. Emmet подключён отдельно для HTML и SCSS, чтобы не спорить с Liquid-подсказками.",[38,158,160],{"id":159},"поставлять-редактор-а-не-компонент","Поставлять редактор, а не компонент",[10,162,163],{},"Редактор появился внутри одного Vue-компонента — редактора кода виджета, где в одном окне шесть файлов. Разметка с Liquid, SCSS, JavaScript и три JSON-файла: переводы, схема формы настроек и значения по умолчанию. У трёх JSON-вкладок один язык, но совершенно разный смысл, поэтому компонент выбирает язык по типу файла, а не по вкладке.",[10,165,166,167,170],{},"Когда редактор понадобился на других экранах, копировать компонент было нельзя. Я вынес сборку в отдельный репозиторий. Webpack с ",[22,168,169],{},"monaco-editor-webpack-plugin"," собирает один продакшен-бандл с семью языковыми идентификаторами Monaco и отдельными файлами веб-воркеров, встраивает шрифты, чтобы их не теряли пути к ассетам, и минифицирует через Terser. Rails Engine и Rake-задача доставляют бандл и воркеры в asset pipeline как версионируемый внутренний gem, а CI собирает бандл, gem и публикует его во внутренний реестр.",[10,172,173],{},"Больше всего возни было с воркерами. Webpack знает исходные имена файлов, а Rails asset pipeline публикует их с хешем в URL. Редактор открывался, но его языковые воркеры не могли запуститься. Маршрутизацию воркеров написал коллега: она находит опубликованные пути и подставляет их в механизм, которым Monaco ищет воркеры.",[10,175,176,177,180],{},"Контракт с продуктом — три функции в ",[22,178,179],{},"window",":",[14,182,187],{"className":183,"code":185,"language":186,"meta":20},[184],"language-js","window.getMonacoInstance = () => monaco\nwindow.monacoCreate = (container, { language, value }) =>\n  monaco.editor.create(container, { ...sharedEditorOptions, language, value })\nwindow.monacoLanguageRegisterApi = { registerLiquid, registerJavaScript, registerScss }\n\nfunction registerLiquid() {\n  const isRegistered = monaco.languages.getLanguages().some(({ id }) => id === 'liquid')\n  if (isRegistered) return\n  \u002F\u002F один раз регистрируем язык, конфигурацию, токенизатор и провайдеры\n}\n","js",[22,188,185],{"__ignoreMap":20},[10,190,191],{},"Для современного изолированного пакета глобальный API я бы не выбрал. Но для существующего Rails-приложения с asset pipeline это был правильный контракт: сборка подключается как ассет, и ей пользуются и старые Rails-страницы, и Vue-экраны. Защита от повторной регистрации нужна на страницах, где редактор создаётся несколько раз: иначе каждая регистрация добавляла бы ещё одну копию провайдеров и подсказок.",[10,193,194],{},"Самое полезное здесь — граница. Пакет отвечает за редактирование: языки, подсказки, Emmet, команды, тему и общие настройки (перенос на 120 символах, автоподстройка под контейнер, сворачивание блоков, отступ в два пробела). Экран, который его встраивает, отвечает за свои данные, сохранение и предпросмотр.",[38,196,198],{"id":197},"пять-интерфейсов-на-одной-сборке","Пять интерфейсов на одной сборке",[10,200,201],{},"Из-за этой границы одна сборка оказалась в пяти интерфейсах: код виджета, полный редактор шаблона витрины, шаблоны писем по заказам, сообщения о брошенных корзинах и уведомления о статусе заказа для email, SMS и мессенджеров. В письмах о брошенных корзинах мы заменили WYSIWYG-редактор, который перекодировал HTML-теги и мог повредить Liquid-логику внутри. В коммуникационных разделах рядом с редактором стоит предпросмотр на данных реального заказа или корзины.",[10,203,204,205,210],{},"Другие разработчики дорабатывали пакет в одном месте, и изменения получали все экраны: коллеги обновили цвета темы и добавили форматирование Liquid через Prettier. Подробнее — в ",[206,207,209],"a",{"href":208},"\u002Fru\u002Fwork\u002Fmonaco-editor\u002F","кейсе о редакторе кода",".",[38,212,214],{"id":213},"что-я-бы-посоветовал-команде-которая-делает-свой-язык-в-monaco","Что я бы посоветовал команде, которая делает свой язык в Monaco",[216,217,218,226,229,235],"ul",{},[219,220,221,222,225],"li",{},"Всё знание о контексте держите в токенизаторе и спрашивайте модель. ",[22,223,224],{},"getLanguageIdAtPosition"," надёжнее любой регулярки по сырому тексту.",[219,227,228],{},"Проектируйте токенизатор вокруг возврата из состояний. Войти во вложенный язык легко, большинство ошибок — в возврате в нужное место.",[219,230,231,232,234],{},"Явно сопоставляйте каждое значение ",[22,233,109],{}," во вложенных блоках с языком и задайте безопасное значение для неизвестных.",[219,236,237],{},"Рано решите, за что отвечает пакет, а за что — экран, который его встраивает. Переиспользуемым редактор сделало именно это решение, а не сам Monaco.",{"title":20,"searchDepth":239,"depth":239,"links":240},2,[241,242,243,244,245,246],{"id":40,"depth":239,"text":41},{"id":91,"depth":239,"text":92},{"id":127,"depth":239,"text":128},{"id":159,"depth":239,"text":160},{"id":197,"depth":239,"text":198},{"id":213,"depth":239,"text":214},"Браузерные редакторы","\u002Fimages\u002Fblog\u002Fmonaco-liquid-tokenizer.svg","Схема одного шаблона витрины, разбитого на области HTML, Liquid, JSON и CSS; редактор спрашивает у модели, какой язык под курсором","2026-10-21","Как собственный токенизатор для HTML с Liquid и вставками JavaScript и CSS научил подсказки следовать за курсором и почему редактор стал версионируемым пакетом с API из трёх функций.","md","ru",{},true,"\u002Fru\u002Fposts\u002Fmonaco-liquid-tokenizer",7,{"title":5,"description":251},"monaco-liquid-tokenizer","ru\u002Fposts\u002Fmonaco-liquid-tokenizer",[262,263,264,265,266],"Monaco","Liquid","Tokenizer","Web workers","Packaging","jkwVQVz4aG8B823jiRTSsZDVWUYuN9L8UaIwGJUqsls",1793195986963]