К содержимому

Переход на 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 $?
1

2. Запустите конвертер #

Сначала сделайте коммит, чтобы 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. Возьмём такой файл:

src/lib/messages/en/common.json
{
	"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 раскладывает один плураль по соседним ключам. Конвертер собирает их в одно сообщение на каждую локаль и сохраняет те категории, что вы написали, поэтому у русского файла ниже получается четыре формы:

src/lib/messages/en/common.json
{
	"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"
}
src/lib/messages/ru/common.json
{
	"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> firstRead {#b}this{/b} firstТеги без атрибутов становятся разметкой MF2, включая <br/>, и предупреждение их перечисляет. Теги с атрибутами остаются текстом, см. шаг 3.
Type '{'name'}' to insert a nameType \{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. Исправьте то, что отметил конвертер #

В этом файле собраны проблемы, которые конвертер сам решить не может. Вывод получен настоящим запуском:

src/lib/messages/en/common.json
{
	"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 codeMF2 не может отформатировать валюту без кода, а ни 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 одно сообщение выбирает ветку по переменной, поэтому контекст становится обычным параметром:

src/lib/messages/en/common.json
{
	"friend": ".input {$context :string}\n.match $context\nmale {{A boyfriend}}\nfemale {{A girlfriend}}\n* {{A friend}}"
}

t('common:friend', { context: 'male' }) // → "A boyfriend"

Вложенность. Сообщения MF2 не могут включать друг друга, поэтому передайте другой перевод параметром или впишите слова прямо:

src/lib/messages/en/common.json
{ "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, и вы импортируете их оттуда.

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() });
src/lib/i18n.ts
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-i18nsvelte-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 — та же самая функция. Значения передаются вторым аргументом напрямую.
$localegetLocale()Реактивна в шаблонах и эффектах, стор не нужен.
locale.set('de')setLocale('de')При включённой маршрутизации по URL setLocale переходит на адрес с префиксом. Передайте { reload: false }, чтобы навигацию делать самому.
$localeslocalesДоступные для выбора локали.
$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:

i18nextsvelte-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.