Skip to content
loclizr
loclizr.config.ts
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.

No codemod: call sites are swapped by hand, with both libraries installed until the last one moves.

  1. Point catalogs at the files, leave catalogFormat at 'auto', and run npx loclizr build --reporter json.

  2. Group the diagnostics by file and code. For each LZ1012 and LZ1013: inline the nested text, name the number style in formats and 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. --quiet does 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 files
    This value nests another key with $t(), which ICU cannot express, so the call renders as literal text.
    de locales/de.json:6:15
    en locales/en.json:6:15
    fr locales/fr.json:6:15
    fix inline the nested text here, or split the sentence into two messages.
    error LZ1013 i18next-format-unsupported cart.total 3 files
    The placeholder {{amount, number}} carries an i18next formatter, which has no ICU equivalent. It renders as the raw value.
    de locales/de.json:4:15
    en locales/en.json:4:15
    fr locales/fr.json:4:15
    fix 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.json
    commit locales/loclizr.context.json; `loclizr check` compares it
    3 messages, 3 locales (source en), 6 errors, 0 warnings
  3. 'tags' if the app used Trans, otherwise the default 'literal'.

  4. One at a time, for a select, a formatted number or a context suffix. Converted files sit beside i18next ones.

  5. 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({ ... })} /> with i18nextMarkup: 'tags'
    t('key', { returnObjects: true }) over an array no equivalent; an array value is LZ1010
    defaultValue in code the catalog only; the source text lives in en.json
  6. Add npx loclizr check to CI.

  7. Only once summary.errors is 0 and no useTranslation is left. The backend and its bundle go last.

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.

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.
src/loclizr/messages/_root.js
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.

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.

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 {...}}"

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.json
commit locales/loclizr.context.json; `loclizr check` compares it
4 messages, 1 locale (source en), 1 error, 0 warnings
src/loclizr/messages/cart.d.ts
// @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): string

m.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.
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.
src/loclizr/messages/_root.d.ts
/** 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:

i18nextMarkup: 'literal'
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.
i18nextMarkup: 'tags'
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 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:

loclizr.config.ts (written by init on an existing catalog)
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.