Эволюция Shiki v1.0
Shiki — это инструмент для подсветки синтаксиса, который использует грамматики и темы TextMate, тот же движок, который поддерживает VS Code. Он обеспечивает одну из самых точных и красивых подсветок синтаксиса для сниппетов кода. Shiki был создан Пайн Ву еще в 2018 году, когда он был частью команды VS Code. Он начинался как эксперимент по использованию Oniguruma для подсветки синтаксиса.
В отличие от существующих подсветчиков синтаксиса, таких как Prism и Highlight.js, которые были разработаны для работы в браузере, Shiki применил другой подход, подсвечивая заранее. Он отправляет подсвеченный HTML клиенту, создавая точную и красивую подсветку синтаксиса с нулем JavaScript. Вскоре Shiki получил широкое распространение и стал очень популярным выбором, особенно для генераторов статических сайтов и сайтов документации.
Хотя Shiki великолепен, это все еще библиотека, разработанная для работы на Node.js. Это означает, что она ограничена подсветкой только статического кода и будет иметь проблемы с динамическим кодом, поскольку Shiki не работает в браузере. Кроме того, Shiki полагается на двоичный файл WASM Oniguruma, а также на кучу тяжелых файлов грамматики и тем в JSON. Он использует файловую систему Node.js и разрешение путей для загрузки этих файлов, которые недоступны в браузере.
Чтобы улучшить эту ситуацию, я начал этот RFC, который позже оказался в этом PR и был отправлен в Shiki v0.9. Хотя он абстрагировал уровень загрузки файлов для использования fetch или файловой системы в зависимости от среды, его все еще довольно сложно использовать, поскольку вам нужно вручную обслуживать грамматики и файлы тем где-то в вашем пакете или CDN, а затем вызывать метод setCDN
, чтобы сообщить Shiki, куда загружать эти файлы.
Решение не идеальное, но, по крайней мере, оно позволило запустить Shiki в браузере для подсветки динамического контента. С тех пор мы использовали этот подход - до тех пор, пока не началась история этой статьи.
Начало
Nuxt прилагает много усилий для продвижения веба на распределенные серверы, делая веб более доступным с меньшей задержкой и лучшей производительностью. Как и серверы CDN, службы граничного хостинга, такие как CloudFlare Workers, развернуты по всему миру. Пользователи получают контент с ближайшего сервера без необходимости совершать путешествие на исходный сервер, который может находиться за тысячи миль. Наряду с потрясающими преимуществами, которые они предоставляют, они также идут на некоторые компромиссы. Например, периферийные серверы используют ограниченное runtime-окружение. CloudFlare Workers также не поддерживает доступ к файловой системе и обычно не сохраняет состояние между запросами. Хотя основные накладные расходы Shiki заключаются в предварительной загрузке грамматик и тем, это не будет хорошо работать в периферийной среде.
Все началось с чата между Себастьяном и мной. Мы пытались сделать Nuxt Content, который использует Shiki для подсветки блоков кода, для работы на периферии.
Я начал эксперименты с локального исправления shiki-es
(сборка Shiki ESM от Пуйя Парса) для преобразования файлов грамматик и тем в модуль ECMAScript (ESM) так, чтобы его можно было понять и объединить инструментами сборки. Это было сделано для создания пакета кода для CloudFlare Workers, который можно было бы использовать без использования файловой системы или выполнения сетевых запросов.
import fs from 'fs/promises'
const cssGrammar = JSON.parse(await fs.readFile('../langs/css.json', 'utf-8'))
const cssGrammar = await import('../langs/css.mjs').then(m => m.default)
Нам нужно было обернуть JSON-файлы в ESM в виде встроенного литерала, чтобы мы могли использовать import()
для их динамического импорта. Разница в том, что import()
— это стандартная функция JavaScript, которая работает везде, в то время как fs.readFile
— это API, специфичный для Node.js, который работает только в Node.js. Наличие import()
статически также позволило бы сборщикам, таким как Rollup и webpack, создавать граф взаимосвязей модулей и выдавать связанный код в виде чанков.
Затем я понял, что на самом деле требуется больше, чтобы заставить его работать в рантайме распределенных серверов. Поскольку сборщики ожидают, что импорты будут разрешимы во время сборки (то есть для поддержки всех языков и тем), нам нужно перечислить все операторы импорта в каждом файле грамматики и темы в кодовой базе. Это приведет к огромному размеру пакета с кучей грамматик и тем, которые вы, возможно, на самом деле не используете. Эта проблема особенно важна в среде периферийных серверов, где размер пакета имеет решающее значение для производительности.
Поэтому нам нужно было найти золотую середину, чтобы все работало лучше.
Форк - Shikiji
Зная, что это может кардинально изменить способ работы Shiki, и поскольку мы не хотим рисковать сломать проекты существующих пользователей Shiki нашими экспериментами, я начал форк Shiki под названием Shikiji. Я переписал код с нуля, сохранив предыдущие решения по дизайну API. Цель состоит в том, чтобы сделать Shiki независимым от рантайма, производительным и эффективным, согласно философии, которой мы придерживаемся в UnJS.
Чтобы это произошло, нам нужно сделать Shikiji полностью дружественным к ESM, чистым и tree-shakable. Это касается и зависимостей Shiki, таких как vscode-oniguruma
и vscode-textmate
, которые предоставляются в формате Common JS (CJS). vscode-oniguruma
также содержит привязку WASM, сгенерированную emscripten
, которая содержит неиспользуемые промисы, из-за которых CloudFlare Workers не смогут завершить запрос. В итоге мы встроили двоичный файл WASM в строку base64 и поставляли его как модуль ES, вручную переписав привязку WASM, чтобы избежать неиспользуемых промисов, и предоставляли его vscode-textmate
для компиляции из исходного кода и создания эффективного вывода ESM.
Конечный результат оказался многообещающим. Нам удалось заставить Shikiji работать в любой среде выполнения, даже с возможностью импортировать его из CDN и запустить в браузере с помощью одной строки кода.
Мы также воспользовались шансом улучшить API и внутреннюю архитектуру Shiki. Мы перешли от простой конкатенации строк к использованию hast
, создав абстрактное синтаксическое дерево (AST) для генерации выходных данных HTML. Это открывает возможность раскрытия Transformers API, чтобы позволить пользователям изменять промежуточный HAST и выполнять множество интересных интеграций, которые ранее было бы очень трудно реализовать.
Поддержка темного/светлого режима была часто запрашиваемой функцией. Из-за статического подхода, который использует Shiki, невозможно было менять тему на лету при рендеринге. Решение в прошлом состояло в том, чтобы дважды генерировать HTML с подсветкой и переключать его видимость на основе предпочтений пользователя — это было неэффективно, так как дублировало payload или использовало CSS переменные темы, что теряло гранулярную подсветку, для которой Shiki отлично подходит. С новой архитектурой Shikiji я сделал шаг назад, переосмыслил проблему и придумал идею разбить общие токены и объединить несколько тем как встроенные переменные CSS, которые обеспечивают эффективный вывод, при этом согласуясь с философией Shiki. Вы можете узнать больше об этом в документации Shiki.
Чтобы упростить миграцию, мы также создали слой совместимости shikiji-compat
, который использует новую основу Shikiji и предоставляет API для обратной совместимости.
Чтобы заставить Shikiji работать на Cloudflare Workers, у нас оставалась последняя проблема, поскольку они не поддерживают инициализацию экземпляра WASM из встроенных двоичных данных. Вместо этого по соображениям безопасности требуется импортировать статические ассеты .wasm
. Это означает, что наш подход "Все-в-ESM" не работает хорошо на CloudFlare. Это потребовало бы от пользователей дополнительной работы по предоставлению различных источников WASM, что делает процесс более сложным, чем мы предполагали. В этот момент Пуйя Парса вмешался и создал универсальный слой unjs/unwasm
, который поддерживает предстоящий пропозал WebAssembly/ES Module Integration. Он был интегрирован в Nitro для автоматизации целей WASM. Мы надеемся, что unwasm
поможет разработчикам улучшить опыт работы с WASM.
В целом, переписанный Shikiji работает хорошо. Nuxt Content, VitePress и Astro были перенесены на него. Отзывы, которые мы получили, также были очень положительными.
Обратное слияние
Я являюсь членом команды Shiki и время от времени помогал делать релизы. Пока Пайн был лидером Shiki, он был занят другими делами, и итерации Shiki замедлились. Во время экспериментов в Shikiji я предложил несколько улучшений, которые могли бы помочь Shiki обрести современную структуру. Хотя в целом все согласились с этим направлением, работы было бы довольно много, и никто не начал над этим работать.
Хотя мы были рады использовать Shikiji для решения наших проблем, мы определенно не хотели видеть сообщество разделенным на две разные версии Shiki. После созвона с Пайн мы пришли к консенсусу объединить два проекта в один:
Мы очень рады видеть, что наша работа в Shikiji была влита обратно в Shiki, что не только работает для нас самих, но и приносит пользу всему сообществу. Благодаря этому слиянию, решается около 95% открытых issues, которые у нас были в Shiki в течение многих лет:
Shiki также получил совершенно новый сайт документации, где вы также можете поиграться с ним прямо в браузере (спасибо агностическому подходу!). Многие фреймворки теперь имеют встроенную интеграцию с Shiki, возможно, вы уже используете его где-то!
Twoslash
Twoslash — это инструмент интеграции для извлечения информации о типе из TypeScript Language Services и генерации в ваш сниппет кода. По сути, он делает статический фрагмент кода похожим на редактор VS Code. Он создан Ортой Тероксом для сайта документации TypeScript, вы можете найти исходный код здесь. Орта также создал интеграцию Twoslash для версий Shiki v0.x. Тогда у Shiki не было надлежащей системы плагинов, из-за чего shiki-twoslash
приходилось создавать как оболочку над Shiki, что немного усложняло настройку, поскольку существующие интеграции Shiki не работали напрямую с Twoslash.
Мы также воспользовались шансом пересмотреть интеграции Twoslash, когда переписывали Shikiji, попробовать "dog-fooding" и проверить расширяемость. С новым внутренним HAST мы можем интегрировать Twoslash как плагин-трансформер, что делает его работающим везде, где работает Shiki, а также использовать в виде композабла с другими трансформерами.
Далее мы начали думать, что, вероятно, сможем заставить Twoslash работать на nuxt.com, веб-сайте, который вы читаете. nuxt.com под капотом использует Nuxt Content, и в отличие от других инструментов документирования, таких как VitePress, одним из преимуществ Nuxt Content является то, что он может обрабатывать динамический контент и работать на распределенных серверах. Поскольку Twoslash полагается на TypeScript, а также на гигантский граф типов модулей из ваших зависимостей, было бы не идеально отправлять все эти вещи на периферийные сервера или в браузер. Звучит сложно, но вызов принят!
Сначала мы пришли к выборке типов из CDN по запросу, используя технику Auto-Type-Acquisition, которую вы увидите на TypeScript playground. Мы создали twoslash-cdn
, который позволяет Twoslash работать в любой среде выполнения. Однако, это звучит как не самое оптимальное решение, так как все равно потребует делать много сетевых запросов, что может свести на нет смысл работы на периферии.
После нескольких итераций на базовых инструментах (например, @nuxtjs/mdc
, компилятора разметки, используемого Nuxt Content), нам удалось применить гибридный подход и создать nuxt-content-twoslash
, который запускает Twoslash во время сборки и кэширует результаты для граничного рендеринга. Таким образом, мы смогли избежать отправки дополнительных зависимостей в финальный пакет, но при этом иметь богатые интерактивные фрагменты кода на веб-сайте:
<script setup>
// Попробуйте навести курсор на идентификаторы ниже, чтобы увидеть типы.
const count = useState('counter', () => 0)
const double = computed(() => count.value * 2)
</script>
<template>
<button>Count is: {{ count }}</button>
<div>Double is: {{ double }}</div>
</template>
В то же время мы воспользовались шансом провести рефакторинг Twoslash с помощью Орты, чтобы иметь более эффективную и современную структуру. Это также позволяет нам иметь twoslash-vue
, который обеспечивает поддержку Vue SFC, как вы видите выше. Он работает на Volar.js и vuejs/language-tools
. Поскольку Volar становится все более независимым от фреймворков, а фреймворки работают вместе, мы с нетерпением ждем, когда в будущем такие интеграции распространятся на большее количество синтаксисов, таких как файлы компонентов Astro и Svelte.
Интеграции
Если вы хотите попробовать Shiki на своем собственном сайте, здесь вы можете найти некоторые интеграции, которые мы сделали:
- Nuxt
- Если используется Nuxt Content, Shiki встроен. Для Twoslash можно сверху добавить
nuxt-content-twoslash
. - Если нет, вы можете использовать
nuxt-shiki
для использования Shiki в качестве компонента Vue или композабла.
- Если используется Nuxt Content, Shiki встроен. Для Twoslash можно сверху добавить
- VitePress
- Shiki встроен. Для Twoslash вы можете использовать
vitepress-twoslash
.
- Shiki встроен. Для Twoslash вы можете использовать
- Низкоуровневые интеграции — Shiki предоставляет официальные интеграции для компиляторов markdown:
markdown-it
- Плагин дляmarkdown-it
rehype
- Плагин дляrehype
Ознакомьтесь с другими интеграциями в документации Shiki
Выводы
Наша миссия в Nuxt — не только создать лучший фреймворк для разработчиков, но и сделать всю экосистему фронтенда и веба лучше. Мы продолжаем расширять границы и поддерживать современные веб-стандарты и лучшие практики. Надеемся, вам понравятся новые Shiki, unwasm, Twoslash и многие другие инструменты, которые мы создали в процессе улучшения Nuxt и веба.