vue-i18n-kit 0.4.0 — редактируй переводы прямо в приложении

Расскажу про 0.4.0 подробно — там много всего, и каждая часть заслуживает объяснения.
Главная тема релиза — in-context editing: редактирование переводов прямо в работающем приложении. Это большая итерация, которую я долго откладывал, потому что хотел сделать нормально, а не просто «показать ключ и поле ввода». В итоге получилось то, чем хочется пользоваться каждый день.
Но начну с визарда — он теперь знает об in-context editing и настраивает всё сам.
Мастер настройки (vue-i18n-kit init) — что изменилось
bash
npx vue-i18n-kit init
Писал про init в прошлый раз — там уже был авто-скан проекта, умный мерж импортов, создание файлов локалей. В 0.4.0 добавился новый шаг: визард теперь предлагает добавить vueI18nDevPlugin в vite.config.ts:
◆ Detected vite.config.ts. Add vueI18nMapPlugin automatically?
│ ● Yes / ○ No
◆ Add vueI18nDevPlugin (in-context translation editor) to vite.config.ts?
│ ● Yes / ○ No
Это два отдельных вопроса: vueI18nMapPlugin нужен всегда (он отдаёт карту локалей UI-серверу), а vueI18nDevPlugin — только если хочешь карандашики в dev-режиме.
Умный мерж импортов
Раньше уже мог быть import { vueI18nMapPlugin } from 'vue-i18n-kit/vite' в конфиге. Если просто добавить новый import { vueI18nDevPlugin } — получишь дубль импорта из того же модуля, что плохо выглядит и технически лишнее.
Визард это учитывает: он найдёт существующую строку импорта и добавит vueI18nDevPlugin прямо в неё:
ts
// было
import { vueI18nMapPlugin } from 'vue-i18n-kit/vite'
// стало
import { vueI18nMapPlugin, vueI18nDevPlugin } from 'vue-i18n-kit/vite'
Если vueI18nNamespacePlugin уже был — точно так же, в тот же импорт. Остальной конфиг не трогается.
Финальный вывод — без рамок
Раньше визард в конце показывал сниппет для main.ts в рамке:
╭─ Add to main.ts ─────────────────────╮
│ import { createVueI18nPlugin } ... │
│ app.use(createVueI18nPlugin({ │
│ ... │
│ })) │
╰───────────────────────────────────────╯
Проблема одна: когда копируешь — символы рамки копируются вместе с кодом. Убрал рамку. Теперь — просто текст с отступом:
◆ Add to main.ts:
import { createVueI18nPlugin } from 'vue-i18n-kit'
app.use(createVueI18nPlugin({
defaultLocale: 'en',
...
}))
// In-context editor: register globals exposed by vueI18nDevPlugin (dev only)
if (import.meta.env.DEV && window.__I18N_KIT_INSPECT_COMPONENT__) {
app.component('I18nInspect', window.__I18N_KIT_INSPECT_COMPONENT__)
}
if (import.meta.env.DEV && window.__I18N_KIT_INSPECT_DIRECTIVE__) {
app.directive('i18n-inspect', window.__I18N_KIT_INSPECT_DIRECTIVE__)
}
Блоки идут один за другим: сначала подключение локалей, потом регистрация in-context globals. Всё в одном месте — скопировал, вставил в main.ts, готово.
In-context editing — редактируй там, где видишь
Типичный рабочий процесс с переводами выглядит так: запускаешь приложение, видишь что-то не то в тексте, идёшь в редактор, ищешь ключ (а их может быть несколько сотен), правишь, переключаешься обратно в браузер, проверяешь — часто выясняется, что исправил не ту реплику или неправильно понял контекст.
Это ручная петля, которую хочется разорвать.
In-context editing решает её в корне: ты видишь неправильный текст в браузере — кликаешь карандаш рядом с ним — меняешь значение прямо здесь — страница обновляется. Без поиска по ключам, без потери контекста.
Как подключить
Два места: vite.config.ts и main.ts.
ts
// vite.config.ts
import { vueI18nDevPlugin, vueI18nMapPlugin } from 'vue-i18n-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueI18nMapPlugin({ locales: { ... } }),
vueI18nDevPlugin(),
],
})
ts
// main.ts — добавить после app.use(createVueI18nPlugin(...))
if (import.meta.env.DEV && window.__I18N_KIT_INSPECT_COMPONENT__) {
app.component('I18nInspect', window.__I18N_KIT_INSPECT_COMPONENT__)
}
if (import.meta.env.DEV && window.__I18N_KIT_INSPECT_DIRECTIVE__) {
app.directive('i18n-inspect', window.__I18N_KIT_INSPECT_DIRECTIVE__)
}
Почему два шага, а не один? Потому что Vite-плагин работает на стороне сервера, а app.component и app.directive — на стороне клиента. Плагин не может напрямую вызвать app.component — у него нет доступа к экземпляру Vue-приложения.
Вместо этого плагин монтирует в document.body свой отдельный Vue-app (DevOverlay) и выставляет компонент и директиву через window-глобалы. Хост-приложение регистрирует их при инициализации — и они становятся частью его дерева компонентов.
В build-режиме весь этот код полностью выпадает: vueI18nDevPlugin — no-op, import.meta.env.DEV в runtime вычисляется в false и tree-shaking убирает обе ветки. В production-бандл ничего не попадает.
Как появляются карандашики

Плагин добавляет Vite transform-хук, который перехватывает каждый .vue файл в dev-режиме перед тем как @vitejs/plugin-vue его компилирует. Находит все интерполяции вида {{ t('key') }}, {{ $t('key') }}, {{ tm('key') }} с литеральными строковыми ключами — и оборачивает каждую:
html
<!-- исходный шаблон -->
<p>{{ t('auth.login.title') }}</p>
<button>{{ t('buttons.submit') }}</button>
<!-- что видит @vitejs/plugin-vue -->
<p><I18nInspect i18n-key="auth.login.title">{{ t('auth.login.title') }}</I18nInspect></p>
<button><I18nInspect i18n-key="buttons.submit">{{ t('buttons.submit') }}</I18nInspect></button>
Компонент I18nInspect рендерит слот как есть, добавляя абсолютно позиционированный карандашик при hover. Никаких изменений в DOM-структуре — только обёртка с иконкой.
Важный момент про Vite: трансформация не происходит на старте сервера. .vue файлы преобразуются лениво — только когда браузер впервые их запрашивает. Открыл / — трансформировались компоненты главной страницы. Перешёл на /dashboard — трансформировались компоненты дашборда. Это стандартное поведение Vite, но оно объясняет, почему карандашики появляются после первой навигации на страницу, а не сразу при запуске.
Что трансформируется, а что нет:
- ✔
{{ t('key') }}— да - ✔
{{ $t('key') }}— да - ✔
{{ tm('key') }}— да - ✘
{{ t(someVar) }}— нет, ключ динамический (для этого есть директива) - ✘
:placeholder="t('key')"— нет, атрибуты не трогаются (техническое ограничение)
Если автообёртка мешает — autoWrap: false в опциях плагина, и <I18nInspect> можно расставлять вручную.
Попап-редактор

Кликнул карандашик — открылся попап. Тёмная тема, всплывает по центру, фон за ним слегка затемняется.
Что внутри: ключ в заголовке, поля ввода для каждой локали с флагом и названием языка. Поля редактируемые, textarea с авторесайзом. Метка «changed» появляется на строке, которую изменил.
Сохранение: Ctrl+Enter (или Cmd+Enter на Mac) — отправляет PUT /api/locale/:code для каждой изменённой локали, остальные не трогает. Если сохранение прошло успешно — краткая зелёная отметка, попап закрывается. Vite HMR подхватывает изменение в JSON — фраза на странице за попапом обновляется сразу.
Если сервер редактора не запущен — попап покажет ошибку с сообщением, никаких молчаливых сбоев.
Клавиши: Escape закрывает попап, клик по тёмному фону тоже. Скролл страницы блокируется пока попап открыт.
Динамические ключи — директива v-i18n-inspect
Авто-обёртка через transform работает на этапе компиляции — она анализирует исходный текст .vue файла. Если ключ не литерал (t(someVar), t('prefix.' + name), t(items[i].key)) — статически его не извлечь.
Для таких случаев есть директива v-i18n-inspect. Она работает в рантайме — берёт значение binding'а как ключ:
html
<!-- динамический ключ через переменную -->
<span v-i18n-inspect="myKey">{{ t(myKey) }}</span>
<!-- ключ из v-for с конкатенацией -->
<li v-for="item in menuItems" :key="item.id"
v-i18n-inspect="'menu.' + item.id">
{{ t('menu.' + item.id) }}
</li>
<!-- ключ зависит от состояния компонента -->
<p v-i18n-inspect="isError ? 'status.error' : 'status.ok'">
{{ t(isError ? 'status.error' : 'status.ok') }}
</p>
Директива работает так же как компонент: при hover на элемент добавляется outline и абсолютно позиционированная кнопка-карандаш. Клик — открывает попап с нужным ключом.
Технически: mounted навешивает mouseenter/mouseleave на el и хранит состояние в WeakMap. updated проверяет binding.oldValue !== binding.value и обновляет ключ если изменился. unmounted убирает все слушатели и восстанавливает стили.
Если у элемента position: static — директива временно устанавливает position: relative чтобы кнопка-карандаш позиционировалась относительно него. При unmounted — восстанавливает как было.
В production-сборке директива никогда не регистрируется (она за import.meta.env.DEV), v-i18n-inspect на элементах молча игнорируется Vue без предупреждений.
Полный редактор — встроенный iframe

Попап-редактор — это быстро исправить одну фразу. Но иногда нужно сесть и проработать целый раздел: заголовки страницы, тексты ошибок, онбординг-тексты. Переключаться туда-обратно между приложением и отдельной вкладкой редактора — неудобно.
Решение: кнопка в шапке попапа открывает полноценный UI редактора прямо в текущей вкладке как боковую панель.
Кликаешь — iframe выезжает справа, попап закрывается. За панелью остаётся видно приложение. Редактор открывается сразу на нужном ключе — URL строится автоматически: http://localhost:4173/?key=auth.login.title&edit=en. Внутри редактор сразу переходит к этому ключу и открывает его на редактирование.
Шапка панели рендерится снаружи iframe, в DevOverlay — чтобы не зависеть от того, что происходит внутри:
- vue-i18n-kit — лого и название
- ✕ — закрыть панель
- ↗ Open in new tab — открыть тот же URL в новой вкладке, панель при этом закрывается
Важный момент: пока iframe открыт, можно продолжать ховерить по репликам в приложении за панелью. Появится попап поверх. Кликнешь «Открыть в редакторе» на другом ключе — src у iframe обновится, панель перейдёт на новый ключ без перезагрузки и закрытия-открытия.
Клавиши: Escape закрывает попап (если открыт). Второй Escape — закрывает iframe. Скролл страницы заблокирован пока открыто что-либо из двух.
Ширина панели настраивается через опцию плагина:
ts
vueI18nDevPlugin({ iframeWidth: '480px' })
По умолчанию — 100vw (на весь экран). Это удобно: видишь редактор в полный размер, ничего не мешает. Для тех, кто хочет видеть и приложение, и редактор одновременно — поставить 480px или 50vw.
Команда vue-i18n-kit dev

Чтобы in-context editing работал, нужно запустить два процесса параллельно: dev-сервер приложения и vue-i18n-kit ui. Это две вкладки терминала, что немного неудобно.
Новая команда запускает оба сразу:
bash
vue-i18n-kit dev
Она читает scripts.dev из package.json проекта и запускает его вместе с UI-сервером. Оба лога смешиваются в одном терминале с цветовым разделением.
bash
vue-i18n-kit dev --ui-port 4200
# если 4173 уже занят
Оба процесса умирают вместе при Ctrl+C — не нужно вручную убивать второй.
Namespace splitting — разбиваем большой JSON на части
Стандартный подход — один en.json на всё приложение. Пока переводов 100–200 ключей это нормально. Но когда их становится 1000+ — файл грузится целиком при каждом запуске, даже если конкретной странице нужен только раздел dashboard.*.
Namespace splitting — это разбивка по первому сегменту ключа. auth.login.title, auth.register.email → auth.json. dashboard.widgets.revenue, dashboard.header → dashboard.json. Каждый namespace — отдельный динамический чанк.
Разбить существующий JSON
bash
vue-i18n-kit split --dir src/locales --out src/locales/split
en.json размером 50kb превращается в:
src/locales/split/
en/
auth.json
dashboard.json
profile.json
settings.json
errors.json
Команда не угадывает структуру — она берёт верхний уровень ключей как имена неймспейсов. auth.login.title → неймспейс auth.
Подключить в Vite
ts
// vite.config.ts
import { vueI18nNamespacePlugin } from 'vue-i18n-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueI18nNamespacePlugin({
locales: {
en: 'src/locales/split/en',
ru: 'src/locales/split/ru',
},
}),
],
})
Плагин регистрирует каждый namespace как отдельный динамический импорт. При навигации на /dashboard — подгружается только dashboard.json. auth.json загрузится когда пользователь дойдёт до страницы авторизации. Бандл на старте становится легче.
Режим в редакторе
В i18n-kit.config.json:
json
{
"namespaces": true
}
С этим флагом редактор переключается в namespace-режим: в тулбаре над таблицей появляются вкладки-таблетки по каждому неймспейсу. Кликаешь — таблица фильтруется, показывает только ключи этого раздела. Удобно работать над конкретным блоком, не листая весь список.
Собрать обратно
Если нужно временно вернуться к плоскому JSON:
bash
vue-i18n-kit merge-ns
Берёт namespace-файлы из директории и объединяет обратно в один en.json.
Translation memory — переводи один раз
Когда работаешь над локализацией достаточно долго, замечаешь: одни и те же фразы встречаются снова и снова. «Сохранить» в кнопке формы. «Отмена» рядом. «Загрузка…» в спиннерах. «Что-то пошло не так» в обработчиках ошибок.
Каждый раз переводить их вручную или вспоминать как ты это переводил в прошлый раз — лишняя работа.
Translation memory решает это: редактор автоматически запоминает каждый перевод, который ты сохранил. При следующем открытии любой ячейки на редактирование — если в памяти есть похожие строки, они появятся как предложения. Нажал на чип — значение вставилось в поле.
Похожесть ищется по исходному тексту (тексту в эталонной локали), а не по ключу. Поэтому если в buttons.save и form.saveButton одинаковый исходный текст «Save» — память предложит уже готовый перевод для второго.
Memory хранится в i18n-kit.memory.json в корне проекта. Можно добавить в .gitignore если не хочется шарить между разработчиками, или наоборот — закоммитить чтобы команда пользовалась общей памятью.
Управление в Settings-панели редактора:
- Clear memory — удалить все записи
- Export memory — скачать JSON
Если память мешает или не нужна совсем:
json
{
"memory": { "enabled": false }
}
i18n Ally — настройка одним вопросом
i18n Ally — VS Code extension, которое показывает переводы прямо в коде рядом с вызовами t(). Удобная вещь, но требует настройки: нужно указать пути к файлам локалей, коды языков, эталонный язык.
vue-i18n-kit init теперь предлагает сгенерировать эту настройку автоматически:
◆ Generate .vscode/settings.json for i18n Ally VS Code extension?
│ ○ Yes / ● No
При Yes записывает .vscode/settings.json:
json
{
"i18n-ally.locales": ["en", "ru"],
"i18n-ally.pathMatcher": "src/locales/{locale}.json",
"i18n-ally.sourceLanguage": "en",
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.keystyle": "nested"
}
Данные берутся из того, что визард уже знает: коды локалей из Шага 1, директория из Шага 2, первая локаль как sourceLanguage.
Если .vscode/settings.json уже существует — новые настройки мержатся в существующий объект, ничего не затирается.
После этого Ally сразу видит ваши ключи и показывает переводы в редакторе без дополнительной настройки.
Итого
Всё это в версии 0.4.0:
bash
npm install vue-i18n-kit@latest
NPM: https://www.npmjs.com/package/vue-i18n-kit
GitHub: https://github.com/macrulezru/vue-i18n-kit