Guides
Importing i18next catalogs
Build an existing i18next JSON catalog in place, then move call sites over one at a time.
import { defineConfig } from 'loclizr'
export default defineConfig({ locales: ['de', 'en'], catalogs: 'public/locales/{locale}/{ns}.json', sourceLocale: 'en', record: 'public/locales/loclizr.context.json',})Point catalogs at your files and run loclizr build. No catalog is rewritten, so i18next keeps
serving the app. The default pattern is locales/{locale}.json
(Configuration); {ns} reads a split layout and prefixes each key, so
common.json keys start with common..
| Line | Why |
|---|---|
locales |
With it unset, every directory in the {locale} position is a locale, so a public/locales/shared/ folder compiles as a locale called shared: LZ1006 warns, and each key it lacks is an LZ3001 error. |
record |
The context record defaults to locales/loclizr.context.json, which would create a locales/ directory beside public/locales/. |
Every message still gets a function, so no page goes blank while you fix the catalog:
| In the catalog | Rule, default | Result |
|---|---|---|
JSON v4, one file per locale or per {ns} |
none | Converts as is. |
{{name}}, {{- name}} |
none | Both become {name} and return the raw value. i18next HTML-escapes {{name}} by default (escapeValue); loclizr never does. |
_one, _other, _zero, _ordinal_* suffixes |
none | Fold into one plural or selectordinal. |
$t() nesting |
LZ1012 i18next-nesting-unsupported, error |
Renders as literal text. |
{{amount, currency}} inline formatter |
LZ1013 i18next-format-unsupported, error |
Renders the raw value. |
{{user.name}}, or any name ICU cannot read |
LZ1013, error |
Literal text; the hint names the flat name. |
JSON v3 X beside X_plural |
LZ1014 plural-suffix-orphan, warn |
Both stay ordinary keys. |
Bare X beside X_one, X_other |
LZ1011 duplicate-key, error |
The folded plural wins. i18next served the bare key only for a call with no count: delete or rename it. |
X_other with no CLDR sibling |
LZ3007 plural-category-incomplete, warn, where the locale has more categories |
Folds alone to X in every locale, so ja and an en source holding only items_other both fold to items; the warning names the items_one en lacks. It stays the key X_other when the file also holds a bare X, or an X_word whose word is not a plural category, such as gender_other beside gender_male. |
Context suffixes friend_male and friend_female |
LZ1017 i18next-context-detected, warn |
Each suffix is its own message. Detected only for _male and _female, and only when the bare friend exists; any other X_word key is an ordinary key. |
| An empty or whitespace-only target value | LZ3002 blank-translation, error |
Falls back to the source text. severity: { 'blank-translation': 'off' } is the equivalent of i18next’s returnEmptyString: false: the fallback stays and the error goes. No setting renders the empty target the way i18next’s default returnEmptyString: true does. |
| An empty source value | none | Renders an empty string. Keys sharing the source text "" group under LZ3012, like any shared text. A blank target under it raises nothing: its fallback renders the same empty string. |
<b>, <a> left by Trans |
LZ1016 i18next-markup-literal, warn |
Literal text until i18nextMarkup: 'tags'. |
| ICU syntax in a file read as i18next | LZ1020 icu-in-i18next-file, warn |
Literal text. |
| An array value | LZ1010 catalog-shape-invalid, error |
No equivalent. |
LZ1015 meta-orphan and LZ1022 meta-placeholder-orphan sit in the same range but belong to the
description sidecar; see Catalogs.
Migrate an app
Section titled “Migrate an app”No codemod: call sites are swapped by hand, with both libraries installed until the last one moves.
-
Build the existing catalog
Section titled “Build the existing catalog”Point
catalogsat the files, leavecatalogFormatat'auto', and runnpx loclizr build --reporter json. -
Fix the errors
Section titled “Fix the errors”Group the diagnostics by
fileandcode. For eachLZ1012andLZ1013: inline the nested text, name the number style informatsand write the ICU form, or flatten a dotted placeholder to one name.Every locale file carries its own copy of
$t()and{{x, fmt}}, so these errors repeat once per file and each file needs the same edit.--quietdoes not thin them, because both rules are errors. The human reporter prints a repeat once and lists every file under it:npx loclizr build, en, de and fr each holding $t(brand) and {{amount, number}} error LZ1012 i18next-nesting-unsupported welcome 3 filesThis value nests another key with $t(), which ICU cannot express, so the call renders as literal text.de locales/de.json:6:15en locales/en.json:6:15fr locales/fr.json:6:15fix inline the nested text here, or split the sentence into two messages.error LZ1013 i18next-format-unsupported cart.total 3 filesThe placeholder {{amount, number}} carries an i18next formatter, which has no ICU equivalent. It renders as the raw value.de locales/de.json:4:15en locales/en.json:4:15fr locales/fr.json:4:15fix name the style in formats.number, convert this file to ICU, and write {amount, number, yourStyle}.wrote src/loclizr (11 files) and locales/loclizr.context.jsoncommit locales/loclizr.context.json; `loclizr check` compares it3 messages, 3 locales (source en), 6 errors, 0 warnings -
Pick
Section titled “Pick i18nextMarkup”i18nextMarkup'tags'if the app usedTrans, otherwise the default'literal'. -
Convert files that need ICU
Section titled “Convert files that need ICU”One at a time, for a select, a formatted number or a context suffix. Converted files sit beside i18next ones.
-
Swap call sites
Section titled “Swap call sites”Component by component; i18next keeps serving the rest.
i18next loclizr t('ns:key')m.ns_key()t('key', { count })m.key({ count })useTranslation('common')m.common_*, imported from the barrel<Trans i18nKey="terms"><Parts of={m.terms({ ... })} />withi18nextMarkup: 'tags't('key', { returnObjects: true })over an arrayno equivalent; an array value is LZ1010defaultValuein codethe catalog only; the source text lives in en.json -
Gate CI
Section titled “Gate CI”Add
npx loclizr checkto CI. -
Remove i18next
Section titled “Remove i18next”Only once
summary.errorsis0and nouseTranslationis left. The backend and its bundle go last.
Auto detection
Section titled “Auto detection”| File | Read as |
|---|---|
Any value contains {{, or an X_other has a CLDR sibling such as X_one |
i18next |
| Otherwise | ICU |
Decided per file, after flattening and before any conversion, so a stray {{ never flips one
string on its own. No hybrid:
single-brace ICU in an i18next file is literal text; {{name}} in an ICU file is a brace around an
argument. A file holding only lone X_other keys and no {{ reads as ICU and keeps the key
X_other, while an i18next source folds its items_other to items; pin catalogFormat: 'i18next'
for such a set.
catalogFormat: 'icu' or 'i18next' pins every file. The example app’s ICU de.json and
i18next de-AT.json sit side by side under the default.
What converts
Section titled “What converts”| i18next | ICU | Notes |
|---|---|---|
{{name}} |
{name} |
Inner whitespace trimmed; typed string | number. i18next HTML-escapes the value by default (escapeValue); loclizr returns it raw. |
{{- name}} |
{name} |
Prefix dropped silently: loclizr never HTML-escapes an argument. |
{{count}} |
{count} |
Never # or Intl.NumberFormat, so 1000 items stays unformatted. A plural selector narrows to number. |
items_one, items_other |
items, one ICU plural |
Selector always count; branches ordered =0, zero, one, two, few, many, other. |
X_ordinal_one |
selectordinal |
|
items_zero |
=0 or zero, by locale |
German never selects zero, so =0. Latvian selects it for 10, 20 and 11 through 19, so zero stays. |
case 'de': if (n0 === 0) return `Dein Warenkorb ist leer` return `${args.count} Artikel in deinem Warenkorb`case 'lv': switch ($plural1('lv', n0, false)) { case 'zero': return `Jūsu grozs ir tukšs` case 'one': return `${args.count} prece jūsu grozā` default: return `${args.count} preces jūsu grozā` }The lv arm passes 'lv' because the category follows the body’s language, not the visitor’s.
The de arm needs no call: its one and other bodies match.
Orphan suffixes
Section titled “Orphan suffixes”A suffixed key with no _other sibling stays an ordinary key and warns. So does a JSON v3 pair:
warn LZ1014 plural-suffix-orphan locales/en.json:9:19 en cart.empty_one
"cart.empty_one" carries a plural suffix with no "cart.empty_other" sibling, so it stays an ordinary key that i18next would never have selected either.
fix add cart.empty_other to fold the group, or rename the key if "_one" was never a plural.warn LZ1014 plural-suffix-orphan locales/en.json:5:21 en cart.item_plural
"cart.item_plural" is the i18next JSON v3 plural of "cart.item". Both stay ordinary keys, each rendering one grammatical number.
fix run i18next's JSON v3 to v4 converter over this catalog, so cart.item_one and cart.item_other fold into one plural message.The build stays green, and m.cart_item({ count: 5 }) renders 5 item in every locale until the
pair is folded, by hand or by i18next’s converter.
Context suffixes
Section titled “Context suffixes”friend, friend_male and friend_female each become a message, and nothing selects between
them any more. The compiler prints the rewrite rather than guess it:
warn LZ1017 i18next-context-detected locales/en.json:11:14 en friend
"friend" carries 2 context suffixes. i18next picks between them with t('friend', { context }); after import each is its own message and no selector picks between them.
friend_male locales/en.json:12:19 its own message; "friend" no longer selects it friend_female locales/en.json:13:21 its own message; "friend" no longer selects it
fix convert this file to ICU first, then replace them with one message: "friend": "{context, select, male {...} female {...} other {...}}"Errors
Section titled “Errors”A four-key catalog (Hi {{name}}, your cart is ready, Welcome back, {{- name}},
Logged in as {{user.name}}, plain nav.home) builds four messages and one error:
error LZ1013 i18next-format-unsupported locales/en.json:5:16 en cart.nested
The placeholder {{user.name}} names "user.name", which ICU cannot read as an argument. It renders as literal text.
fix give the argument one plain name: write {{userName}} here and pass userName at the call site.
wrote src/loclizr (11 files) and locales/loclizr.context.jsoncommit locales/loclizr.context.json; `loclizr check` compares it4 messages, 1 locale (source en), 1 error, 0 warnings// @generated by loclizr abi=1. Do not edit; run `loclizr build`.import type { EmptyArgs, MessageOptions } from 'loclizr'/** en: "Hi {name}, your cart is ready" */export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): string/** en: "Logged in as '{{'user.name'}}'" */export declare function cart_nested(args?: EmptyArgs, opts?: MessageOptions): string/** en: "Welcome back, {name}" */export declare function cart_raw(args: { name: string | number }, opts?: MessageOptions): stringm.cart_nested() takes nothing and renders Logged in as {{user.name}}, as i18next did for a
missing value. Its doc comment quotes the braces, as canonical ICU writes literal text. The build
exits 1 until the fix lands.
Nesting and inline formatters print the same way:
error LZ1012 i18next-nesting-unsupported locales/en.json:8:16 en cart.footer
This value nests another key with $t(), which ICU cannot express, so the call renders as literal text.
fix inline the nested text here, or split the sentence into two messages.
error LZ1013 i18next-format-unsupported locales/en.json:7:15 en cart.total
The placeholder {{amount, currency}} carries an i18next formatter, which has no ICU equivalent. It renders as the raw value.
fix name the style in formats.number, convert this file to ICU, and write {amount, number, yourStyle}.ICU syntax in a file read as i18next is the one mistake auto detection could hide, so it warns:
warn LZ1020 icu-in-i18next-file locales/en.json:15:14 en status
"{state, select" is ICU argument syntax, and this file is read as i18next, so the whole run renders as literal text.
fix this file classified as i18next on "Hi {{name}}, your cart is ready". Convert the file to ICU, or set severity: { 'icu-in-i18next-file': 'off' } if the literal text is what you meant.HTML in values
Section titled “HTML in values”i18nextMarkup |
Effect |
|---|---|
'literal', the default |
Tags render as the characters, as in i18next. LZ1016 once per value; the return stays string. |
'tags' |
Catalog wide, tags lower to markup arguments: b becomes a required handler and every call site’s return type changes. LZ1016 goes quiet. |
/** en: "Read our <b>terms</b> before you continue." */export declare function terms<T>( args: { b: (chunks: readonly (string | T)[]) => T }, opts?: MessageOptions,): readonly (string | T)[]Some tags never lower: a numbered <1>, attributes, an unclosed or mismatched tag, a name cut by
a placeholder, a self-closing <br/>. Under 'tags' such a value stays text with its own
LZ1016, so one stray tag never changes a signature. A catalog holding terms,
"accept": "I accept the <1>terms of service</1>" and "greeting": "Hi {{name}}", which makes
the file read as i18next, warns per mode:
warn LZ1016 i18next-markup-literal locales/en.json:2:13 en terms
Tag-shaped text in this value was escaped to literal text, which is what i18next itself rendered.
fix set i18nextMarkup: 'tags' to lower these tags to markup arguments instead.warn LZ1016 i18next-markup-literal locales/en.json:3:14 en accept
The tag </1> cannot lower to a markup argument, so every tag in this value was escaped to literal text, which is what i18next's t() rendered.
fix a tag lowers when its name starts with an ASCII letter, it carries no attributes and it is closed by a matching closing tag, as in <link>...</link> (a self-closing <br/> stays text): name a numbered tag and move attributes to the call site.m.accept() renders I accept the <1>terms of service</1> in both modes; 'literal' stays
silent, since <1> never parses as a tag. Name the tag, <link>terms of service</link>, and under
'tags' it lowers to a handler as <b> does.
Warnings on the first build
Section titled “Warnings on the first build”Warnings alone never fail a build: --max-warnings has no cap unless you set one.
An import usually meets LZ3012 ambiguous-source first, at warn. It fires wherever keys share
source text and at least one has no description, so on an imported catalog every shared “Save”
fires. loclizr init on an existing catalog leaves its severity at warn and writes the hardening
line commented out:
import { defineConfig } from 'loclizr'
export default defineConfig({ locales: ['de', 'en'], sourceLocale: 'en', catalogs: 'public/locales/{locale}/{ns}.json', meta: 'public/locales/{sourceLocale}.meta.json', record: 'public/locales/loclizr.context.json', outDir: 'src/loclizr', // Turn this on once public/locales/en.meta.json describes your keys. It // reports keys that share source text where at least one of them has no // description, so on a catalog imported without descriptions it fires on // every shared string at once. // severity: { 'ambiguous-source': 'error' },})For a tree outside locales/, init also puts meta and record beside the catalogs.