Переход на MessageFormat 2 с svelte-i18n и i18next
Запустите svelte-dialect migrate на файлах переводов, и они превратятся в MessageFormat 2. Всё, что конвертер не может перевести в корректный MF2, остаётся как было, с предупреждением. На этой странице разобрана одна миграция от начала до конца, с настоящим выводом команды.
Для кого эта страница #
Для приложений, где переводы записаны строками ICU MessageFormat (так работают svelte-i18n и formatjs) или JSON-файлами i18next с плейсхолдерами {{name}} и плюралями key_one / key_other. MessageFormat 2 — преемник ICU MessageFormat от Unicode. Плюрали, селекты и форматирование чисел остались, поменялся синтаксис. CLI переписывает файлы сообщений, а шаг 4 сопоставляет вызовы в рантайме.
1. Установка #
Поставьте svelte-dialect рядом с текущей библиотекой. Конвертер только читает и пишет файлы сообщений, поэтому на время перехода в проекте могут жить обе.
npm install svelte-dialectРасположение файлов #
CLI ожидает src/lib/messages/<locale>/<namespace>.json, то есть ту же раскладку, что читает рантайм. У i18next locales/<lng>/<ns>.json уже так устроен. svelte-i18n хранит по одному en.json на язык: переложите каждый в папку, и имя файла станет пространством имён. Папки локалей называются тегами BCP 47, поэтому pt-BR и zh-Hant подходят как есть. Папка zh_CN пропускается: команда предупредит и попросит переименовать её в zh-CN.
src/lib/messages/
en/common.json # was en.json
ru/common.json # was ru.jsonФайл <locale>.json, лежащий прямо в каталоге сообщений, пропускается с предупреждением. Если после этого мигрировать нечего, команда завершается с кодом 1:
$ npx svelte-dialect migrate
src/lib/messages/en.json: skipped — it sits directly in the messages directory. Move it to en/common.json (any namespace name works)
src/lib/messages/ru.json: skipped — it sits directly in the messages directory. Move it to ru/common.json (any namespace name works)
svelte-dialect migrate: 0 messages converted, 2 warnings
svelte-dialect migrate: nothing was migrated — move each <locale>.json into <locale>/<namespace>.json
$ echo $?
12. Запустите конвертер #
Сначала сделайте коммит, чтобы diff показал, что изменилось. Затем пробный запуск: он печатает каждое изменение парой «до» и «после» и ничего не пишет:
npx svelte-dialect migrate --dry-runБез --dry-run файлы записываются. --messages <dir> задаёт другой каталог и может повторяться; по умолчанию это src/lib/messages. Файл переписывается (JSON с отступами табами) только если изменилось хотя бы одно его сообщение. Предупреждения идут в stderr. Код выхода равен 0, если не указан --strict и в каталоге есть не только плоские файлы <locale>.json. Повторный запуск ничего не меняет, поэтому после ручных правок его можно запускать снова. Все флаги и коды выхода собраны в справочнике CLI.
Каждый результат конвертер компилирует парсером MF2, прежде чем его оставить. Если результат не разбирается или в исходнике есть то, чего конвертер не знает, исходная строка остаётся в файле, а предупреждение называет файл и ключ.
ICU MessageFormat (svelte-i18n, formatjs) #
Сообщения svelte-i18n — это строки ICU. Возьмём такой файл:
{
"greeting": "Hello, {name}!",
"cart": {
"items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"
},
"invite": "{gender, select, female {She invited you} male {He invited you} other {They invited you}}"
}$ npx svelte-dialect migrate --dry-run
svelte-dialect migrate: en/common.json — 3 converted, 0 unchanged, 0 warnings
greeting
- Hello, {name}!
+ Hello, {$name}!
cart.items
- {count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}
+ .input {$count :number}
+ .match $count
+ 0 {{Your cart is empty}}
+ one {{{$count} item in your cart}}
+ * {{{$count} items in your cart}}
invite
- {gender, select, female {She invited you} male {He invited you} other {They invited you}}
+ .input {$gender :string}
+ .match $gender
+ female {{She invited you}}
+ male {{He invited you}}
+ * {{They invited you}}
svelte-dialect migrate: 3 messages would change, 0 warnings (dry run, nothing written)Плураль становится одним сообщением с .match. Ветки превращаются в варианты, =0 в точный ключ 0, other в универсальный *, а # в {$count}. Select работает так же, только с :string вместо :number.
i18next #
i18next раскладывает один плураль по соседним ключам. Конвертер собирает их в одно сообщение на каждую локаль и сохраняет те категории, что вы написали, поэтому у русского файла ниже получается четыре формы:
{
"welcome": "Welcome, {{name}}!",
"item_one": "{{count}} item",
"item_other": "{{count}} items",
"inbox_zero": "Inbox zero",
"inbox_one": "One message",
"inbox_other": "{{count}} messages",
"rank_ordinal_one": "{{count}}st place",
"rank_ordinal_two": "{{count}}nd place",
"rank_ordinal_few": "{{count}}rd place",
"rank_ordinal_other": "{{count}}th place"
}{
"welcome": "Добро пожаловать, {{name}}!",
"item_one": "{{count}} товар",
"item_few": "{{count}} товара",
"item_many": "{{count}} товаров",
"item_other": "{{count}} товара"
}$ npx svelte-dialect migrate --dry-run
svelte-dialect migrate: en/common.json — 4 converted, 0 unchanged, 1 warnings
welcome
- Welcome, {{name}}!
+ Welcome, {$name}!
item
- item_*
+ .input {$count :number}
+ .match $count
+ one {{{$count} item}}
+ * {{{$count} items}}
inbox
- inbox_*
+ .input {$count :number}
+ .match $count
+ 0 {{Inbox zero}}
+ one {{One message}}
+ * {{{$count} messages}}
rank_ordinal
- rank_ordinal_*
+ .input {$count :number select=ordinal}
+ .match $count
+ one {{{$count}st place}}
+ two {{{$count}nd place}}
+ few {{{$count}rd place}}
+ * {{{$count}th place}}
common.json: rank_ordinal: i18next ordinal plural converted to ":number select=ordinal" — call t('common:rank_ordinal', { count }) instead of t('common:rank', { count, ordinal: true })
svelte-dialect migrate: ru/common.json — 2 converted, 0 unchanged, 0 warnings
welcome
- Добро пожаловать, {{name}}!
+ Добро пожаловать, {$name}!
item
- item_*
+ .input {$count :number}
+ .match $count
+ one {{{$count} товар}}
+ few {{{$count} товара}}
+ many {{{$count} товаров}}
+ * {{{$count} товара}}
svelte-dialect migrate: 6 messages would change, 1 warnings (dry run, nothing written)item_one и item_other стали ключом item. Ключ _zero в i18next означает count, равный ровно 0. Конвертер делает так же: inbox_zero превращается в точный вариант 0, а не в категорию zero, которой в большинстве языков нет. Порядковые плюрали устроены иначе: rank_ordinal_one, rank_ordinal_two и остальные становятся одним сообщением rank_ordinal, которое выбирает ветку через select=ordinal, так что 2 выводится как 2nd. i18next находил их вызовом t('rank', {count, ordinal: true}); теперь вызов выглядит как t('common:rank_ordinal', {count}), и предупреждение об этом напоминает. Обычные rank_one и rank_other рядом с ними по-прежнему сворачиваются в rank.
Что конвертируется #
У конвертера собственный парсер той части ICU и i18next, которая встречается в настоящих каталогах: аргументы, number, date и time, plural, selectordinal, select, # и экранирование апострофом, двойные фигурные скобки i18next и HTML-теги без атрибутов. Всю грамматику ICU он не покрывает.
| До | После | Примечания |
|---|---|---|
| Hello, {name}! | Hello, {$name}! | Обычные плейсхолдеры. {{name}} из i18next конвертируется так же. |
| {{- html}} | {$html} | Неэкранируемый плейсхолдер i18next. MF2 не экранирует HTML в значениях, так что отключать нечего. |
| Score: {score, number, integer} | Score: {$score :integer} | Стили number integer и percent, скелет ::percent и currency соответствуют функции с тем же именем. То же верно для форматов i18next: number, integer, currency, percent, date, time, datetime и unit. |
| Due {d, date, short} | Due {$d :date length=short} | Стили short, medium и long становятся опцией length. У full своей длины нет, поэтому добавляется день недели: fields=year-month-day-weekday вместе с length=long. |
| At {d, time, medium} | At {$d :time precision=second} | У функции :time нет стилей. short — её значение по умолчанию, medium добавляет секунды через precision=second, а long и full добавляют название часового пояса через timeZoneStyle=short и timeZoneStyle=long. |
| {n, number, ::percent} done | {$n :percent} done | ::percent — единственный числовой скелет, который покрывает :percent. Остальные скелеты по-прежнему получают предупреждение. |
| Read <b>this</b> first | Read {#b}this{/b} first | Теги без атрибутов становятся разметкой MF2, включая <br/>, и предупреждение их перечисляет. Теги с атрибутами остаются текстом, см. шаг 3. |
| Type '{'name'}' to insert a name | Type \{name\} to insert a name | Кавычки-апострофы ICU становятся обратной косой чертой. |
| {count, plural, one {{gender, select, male {He has one hat} other {They have one hat}}} other {{gender, select, male {He has # hats} other {They have # hats}}}} | .input {$count :number} .input {$gender :string} .match $count $gender one male {{He has one hat}} one * {{They have one hat}} * male {{He has {$count} hats}} * * {{They have {$count} hats}} | Select, вложенный в plural, разворачивается в один .match по обеим переменным, с вариантом на каждое сочетание, не больше 64 вариантов. |
3. Исправьте то, что отметил конвертер #
В этом файле собраны проблемы, которые конвертер сам решить не может. Вывод получен настоящим запуском:
{
"total": "Total: {$total :currency}",
"price": "Total: {$price :currency}",
"place": ".input {$place :number select=ordinal}\n.match $place\none {{You finished {$place}st}}\ntwo {{You finished {$place}nd}}\nfew {{You finished {$place}rd}}\n* {{You finished {$place}th}}",
"views": "{$views :number} views",
"spell": "{n, spellout} items",
"people": "{n, plural, offset:1 =0 {Nobody} other {# people}}",
"shout": "{$name}",
"friend": "A friend",
"friend_male": "A boyfriend",
"friend_female": "A girlfriend",
"back": "Back to $t(welcome)",
"docs": "Read the <a href=\"/docs\">docs</a>"
}$ npx svelte-dialect migrate
svelte-dialect migrate: en/common.json — 5 converted, 7 unchanged, 10 warnings
common.json: total: "total" became :currency, which needs a code — write {$total :currency currency=EUR} or pass one at runtime
common.json: price: "price" became :currency, which needs a code — write {$price :currency currency=EUR} or pass one at runtime
common.json: place: "place" used ICU selectordinal — converted to ":number select=ordinal"; MF2 ordinal categories can differ, review the arms
common.json: views: style "::compact-short" dropped from "{views, number, ::compact-short}"
common.json: spell: unsupported ICU type "spellout"
common.json: people: plural offset is not supported
common.json: shout: format "uppercase" dropped from "{{name, uppercase}}" — "name" is now a plain {$name}
common.json: back: i18next nesting "$t(…)" has no MF2 equivalent — pass the other translation in as a parameter, or split the message
common.json: docs: HTML <a href="/docs"> left as text — markup carries no attributes. Write {#name}…{/name}, render it with the Rich component and set the attributes in its snippet
common.json: friend: "friend_male", "friend_female" look like i18next context variants — MF2 handles context with a .match on a parameter; ignore this if they are separate messages
svelte-dialect migrate: 5 messages converted, 10 warningsКаждое предупреждение и что с ним делать:
| Предупреждение | Что делать |
|---|---|
| "total" became :currency, which needs a code | MF2 не может отформатировать валюту без кода, а ни ICU, ни i18next его не дали. Впишите код в сообщение, например {$total :currency currency=EUR}. Тогда одно и то же сообщение выводит €1,234.50 по-английски и 1.234,50 € по-немецки. |
| "place" used ICU selectordinal | Сообщение конвертировано, ветки скопированы как были. Сверьте их с порядковыми категориями каждой локали, которую вы поставляете. В английском это one, two, few и универсальный вариант, в других языках категории другие. |
| style "::compact-short" dropped from "{views, number, ::compact-short}" | Конвертер переводит стили даты и времени ICU и стиль percent. То, что он перевести не может, отбрасывается, и значение форматируется по умолчанию: это собственные шаблоны дат вроде dd/MM/yyyy и числовые скелеты вроде ::compact-short. Для даты верните поля вручную, например {$due :date fields=month-day}. У :number из messageformat нет компактной записи чисел, поэтому добавить нечего: отформатируйте значение в коде через formatNumber и передайте готовую строку. |
| unsupported ICU type "spellout" plural offset is not supported | Эти строки остаются в формате ICU, а он не является корректным MF2, поэтому в рантайме вместо текста показывается ключ. Перепишите их вручную. Для offset вычислите скорректированное число в коде и передайте его отдельным параметром. |
| format "uppercase" dropped from "{{name, uppercase}}" | Формат отбрасывается, а значение остаётся обычной переменной, здесь {$name}. Преобразуйте значение в коде до передачи или зарегистрируйте функцию через опцию functions и вызовите её в сообщении. |
| i18next nesting "$t(…)" has no MF2 equivalent | Сообщения MF2 не могут включать друг друга. Текст остаётся как был, и вызов $t(…) выводится буквально. Передайте другой перевод параметром или впишите слова прямо, как показано ниже. |
| HTML <a href="/docs"> left as text | У разметки MF2 нет атрибутов. Напишите в сообщении {#a}docs{/a}, отрисуйте его компонентом Rich и задайте href в сниппете. Теги без атрибутов уже конвертированы, предупреждение их перечисляет. |
| "friend_male", "friend_female" look like i18next context variants | Варианты остаются отдельными ключами, и никто их не запрашивает, потому что в MF2 нет аргумента context. Объедините их в одно сообщение, которое выбирает ветку по параметру, как показано ниже. Если ключи — несвязанные сообщения с общим префиксом, предупреждение можно игнорировать. |
| i18next ordinal plural converted to ":number select=ordinal" | В файле менять нечего. Замените вызовы t('rank', {count, ordinal: true}) на t('common:rank_ordinal', {count}). Ветки скопированы как были, сверьте их с порядковыми категориями каждой локали. |
Контекст, вложенность и ссылки вручную #
Контекст. friend_male и friend_female остаются отдельными ключами. В MF2 одно сообщение выбирает ветку по переменной, поэтому контекст становится обычным параметром:
{
"friend": ".input {$context :string}\n.match $context\nmale {{A boyfriend}}\nfemale {{A girlfriend}}\n* {{A friend}}"
}
t('common:friend', { context: 'male' }) // → "A boyfriend"Вложенность. Сообщения MF2 не могут включать друг друга, поэтому передайте другой перевод параметром или впишите слова прямо:
{ "back": "Back to {$welcome}" }
t('common:back', { welcome: t('common:welcome', { name }) })Ссылки. У MF2 своя разметка, {#link}docs{/link}, и компонент Rich отрисовывает её через сниппеты. Именно там задаётся href.
Падать в CI из-за предупреждений #
По умолчанию предупреждения не влияют на код выхода. С флагом --strict команда завершается с кодом 1, если вывела хотя бы одно предупреждение, так что им можно закрыть пайплайн. Вместе с --dry-run он проверяет ветку, ничего не записывая. Ниже второй запуск над файлом из примера выше, после того как первый сконвертировал всё, что смог:
$ npx svelte-dialect migrate --strict
svelte-dialect migrate: en/common.json — 0 converted, 12 unchanged, 5 warnings
common.json: spell: unsupported ICU type "spellout"
common.json: people: plural offset is not supported
common.json: back: i18next nesting "$t(…)" has no MF2 equivalent — pass the other translation in as a parameter, or split the message
common.json: docs: HTML <a href="/docs"> left as text — markup carries no attributes. Write {#name}…{/name}, render it with the Rich component and set the attributes in its snippet
common.json: friend: "friend_male", "friend_female" look like i18next context variants — MF2 handles context with a .match on a parameter; ignore this if they are separate messages
svelte-dialect migrate: 0 messages converted, 5 warnings
svelte-dialect migrate: --strict: 5 warnings, exiting with 1
$ echo $?
1Предупреждения о строках, которые так и остались неконвертированными, повторяются при каждом запуске. Предупреждение о самой конвертации, например про отсутствующий код валюты, появляется один раз, на запуске, который её выполнил. Чтобы увидеть всё, запустите --strict --dry-run до первого настоящего запуска.
4. Замените вызовы в рантайме #
CLI трогает только файлы сообщений, так что компоненты по-прежнему вызывают старую библиотеку. В svelte-dialect нет сторов. t, getLocale и форматтеры — обычные функции, которые следят за руной локали, поэтому в шаблонах пропадает префикс $, а обновление при смене локали остаётся.
Каждый ключ содержит пространство имён, как в t('common:cart.items'). Имя файла — это пространство имён, а вложенные объекты разворачиваются в пути через точку. Ключ без двоеточия не ищется, и t возвращает его как есть.
С svelte-i18n #
svelte-i18n настраивает глобальный стор. svelte-dialect один раз создаёт свои функции в src/lib/i18n.ts, и вы импортируете их оттуда.
import { init, register, getLocaleFromNavigator } from 'svelte-i18n';
register('en', () => import('./locales/en.json'));
register('ru', () => import('./locales/ru.json'));
init({ fallbackLocale: 'en', initialLocale: getLocaleFromNavigator() });import { createDialect } from 'svelte-dialect/kit';
export const { t, loadTranslations, getLocale, setLocale, locales } = createDialect({
messages: import.meta.glob('./messages/**/*.json', { eager: true }),
defaultLocale: 'en',
locales: ['en', 'ru']
});В SvelteKit добавьте хуки из раздела «Установка». Хук handle определяет локаль на сервере.
В компонентах $_ становится t, обёртка values исчезает, а ключ получает пространство имён:
<script>
import { _, locale } from 'svelte-i18n';
</script>
<h1>{$_('cart.items', { values: { count: 3 } })}</h1>
<button onclick={() => locale.set('ru')}>RU</button><script lang="ts">
import { t, setLocale } from '#lib/i18n.js';
</script>
<h1>{t('common:cart.items', { count: 3 })}</h1>
<button onclick={() => setLocale('ru')}>RU</button>Остальной API:
| svelte-i18n | svelte-dialect | Примечания |
|---|---|---|
| init({ fallbackLocale, initialLocale }) | createDialect({ defaultLocale, locales }) | defaultLocale — запасная локаль. Ключ, которого нет в активной локали, берётся из соседних вариантов того же языка, затем из неё. |
| addMessages('en', dict) | messages: import.meta.glob('./messages/**/*.json', { eager: true }) | Собирается на этапе сборки. |
| register('en', () => import('./en.json')) | messages: import.meta.glob('./messages/**/*.json') | Без eager каждое пространство имён — отдельный чанк. Опция loader вместо этого запрашивает данные из вашего API. |
| await waitLocale() | await loadTranslations('common') | Нужен только для ленивых или запрашиваемых пространств имён. Возвращает t и подгружает заново при смене локали. |
| $_('a.b', { values: { n } }) | t('common:a.b', { n }) | $t и $format — та же самая функция. Значения передаются вторым аргументом напрямую. |
| $locale | getLocale() | Реактивна в шаблонах и эффектах, стор не нужен. |
| locale.set('de') | setLocale('de') | При включённой маршрутизации по URL setLocale переходит на адрес с префиксом. Передайте { reload: false }, чтобы навигацию делать самому. |
| $locales | locales | Доступные для выбора локали. |
| $number(n), $date(d), $time(d) | formatNumber(n, options), formatDate(d, options) | Оба используют Intl. $time — это formatDate с опциями часов и минут, а именованные форматы вроде medium превращаются в объекты опций или в опции MF2, например length=long. |
| getLocaleFromNavigator(), getLocaleFromPathname() | strategy: ['url', 'cookie', 'navigator', 'default'] | Источники перебираются по порядку. Встроенных аналогов getLocaleFromHostname, getLocaleFromQueryString и getLocaleFromHash нет: прочитайте значение сами и вызовите setLocale. |
| $isLoading | (нет) | loadTranslations возвращает промис. Дожидайтесь его в load-функции вместо индикатора загрузки. |
| $json('list.items') | (нет) | Сообщения — строки. Массивы и другие не строковые значения отбрасываются при загрузке каталога, поэтому списки храните в коде или заведите по ключу на элемент. |
| $_('key', { default: '…' }) | (нет) | Отсутствующий ключ ищется по цепочке локалей и в конце выводится как сам ключ. check ловит статические ключи раньше. |
С i18next #
Вызовы здесь ближе, потому что t('ns:key', params) уже выглядит так же. Перепишите настройку, затем места, которые обходят t:
| i18next | svelte-dialect | Примечания |
|---|---|---|
| i18next.init({ lng, fallbackLng, resources }) | createDialect({ defaultLocale, locales, messages }) | messages принимает те же файлы, что у вас уже есть, в раскладке из шага 1. |
| i18next.t('ns:key', { count }) | t('ns:key', { count }) | Те же соглашения ns:key и путей через точку. |
| i18next.t('rank', { count, ordinal: true }) | t('common:rank_ordinal', { count }) | Флаг ordinal превращается в суффикс ключа. Для каждой конвертированной порядковой группы миграция выводит предупреждение. |
| i18next.t('key') | t('translation:key') | Пространства имён по умолчанию нет. В i18next оно называется translation, поэтому пишите пространство имён в каждом ключе или переименуйте файл. |
| await i18next.changeLanguage('de') | setLocale('de') | Синхронная. Ленивые пространства имён подгружаются сами. |
| i18next.loadNamespaces('ns') | await loadTranslations('common') | Нужен только для ленивых или запрашиваемых пространств имён. |
| t('friend', { context: 'male' }) | t('common:friend', { context: 'male' }) | После ручной конвертации из шага 3 context — обычный параметр. Назовите его так, как называется переменная, по которой выбирает сообщение. |
| i18next.dir() | getTextDirection() | Возвращает ltr или rtl. |
| i18next.getFixedT(null, 'ns') | (нет) | Ключи всегда содержат пространство имён. Если нужен короткий вариант, оберните t в свою функцию. |
| interpolation.escapeValue | (нет) | i18next по умолчанию экранирует HTML в подставляемых значениях. svelte-dialect этого не делает, потому что Svelte сам экранирует выводимый текст. Экранируйте значения сами, прежде чем отдавать результат t() в {@html}. |
5. Проверьте результат #
Сгенерируйте типы ключей из мигрированных файлов, и формы параметров появятся вместе с ними: из исходника MF2 видно, что count — число, а gender — строка. Затем запустите check, который сверяет каждый вызов t('…') в вашем коде с каталогами. Здесь в коде опечатка common:cart.itms, а в русском файле есть только одно сообщение из трёх:
$ npx svelte-dialect typegen
svelte-dialect typegen: 3 keys → src/lib/i18n-keys.d.ts
$ npx svelte-dialect check
Missing:
common:cart.items — missing in ru
common:cart.itms — missing in en, ru
common:invite — missing in ru
4 keys used, 3 missing// src/lib/i18n-keys.d.ts (generated)
export type MessageParams = {
'common:cart.items': { count: number | string };
'common:greeting': { name: unknown };
'common:invite': { gender: string };
};check завершается с кодом 1, если используемого ключа нет хотя бы в одной локали, так что его удобно поставить в CI. Обе команды описаны на странице «Типизированные ключи и CLI». Ещё прочитайте глазами несколько сконвертированных плуралей на каждом языке. Конвертер сохраняет категории, которые вы написали, и не знает, какие нужны вашему языку.
Дальше #
Страница «Файлы сообщений» описывает синтаксис MF2, на котором теперь написаны ваши файлы. «Загрузка сообщений» заменяет register и загрузчики, а «Локаль в URL» заменяет getLocaleFromPathname. «Сравнение с другими» ставит svelte-dialect рядом с другими i18n-библиотеками для Svelte.