This is the abridged developer documentation for loclizr # Typed message functions and catalog checks from JSON > A compiler for the contract between code and translations. Open source, MIT licensed # Checking your catalog shouldn't be a tool you remember to add. loclizr build turns your JSON translation catalogs into typed message functions, checks the catalogs on every build, and writes a context record for whoever translates next. [Get started](https://yasinmiran.github.io/loclizr/start/quickstart/)[View on GitHub](https://github.com/yasinmiran/loclizr) `npm i loclizr` This page is rendered by loclizr. Switch the language in the header and it changes without a reload. One catalog line, one build, one typed call: `m.demo_items` renders in all 13 locales, and a wrong argument type fails `tsc`. ## Typed message functions Plain ESM with declarations, one function per message. A renamed placeholder breaks the call site at typecheck, not in production. ## Checks that are already on Missing translations, ICU syntax errors and arguments that differ between locales fail the build. ## A context record per message Argument types, plural shape and where each message is used, committed in the same pull request as the string change. ## Sixty seconds A JSON catalog in, ICU MessageFormat by default. i18next catalogs are detected per file and converted in memory. locales/en.json ```json { "cart": { "greeting": "Hi {name}, your cart is ready", "items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" } } ``` ```sh npx loclizr build ``` Typed functions out. The locale is read at call time: `setLocale('de')` needs no reload. src/Cart.tsx ```ts import * as m from './loclizr/messages' m.cart_greeting({ name: 'Ada' }) // "Hi Ada, your cart is ready" m.cart_items({ count: 3 }) // "3 items in your cart" m.cart_items({ count: '3' }) // type error: count is a number ``` ## Try it LanguageEnglish (en) (en)NameAdaItems3Statusshipped Hello, Ada! 3 items in your cart Total: €73.50 On its way Updated Oct 3, 2026 `m.demo_items({ count: 3 }, { locale: 'en' })` This site compiles its own catalogs with loclizr. The [Playground](https://yasinmiran.github.io/loclizr/start/playground/) shows one count in all 13 languages at once. ## One pass, three outputs The entry for `cart.greeting` in the example app (array indentation removed): locales/loclizr.context.json (one entry) ```json { "key": "cart.greeting", "id": "cart_greeting", "module": "messages/cart.js", "kind": "text", "source": "Hi {name}, your cart is ready", "sourceHash": "54db56b1aeaf77ae", "description": null, "args": [ { "name": "name", "type": "text", "options": null, "note": null } ], "variants": [], "markup": [], "translations": [ { "locale": "de", "status": "translated", "from": null, "reason": null }, { "locale": "de-AT", "status": "translated", "from": null, "reason": null }, { "locale": "en", "status": "translated", "from": null, "reason": null } ], "usage": [ { "file": "src/App.tsx", "scope": "App" } ] } ``` ## What a failing build looks like ### Break a German translation `{name}` became `{nmae}`. locales/de.json ```json { "cart": { "greeting": "Hallo {nmae}, dein Warenkorb ist bereit", "items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel im Warenkorb} other {# Artikel im Warenkorb}}" } } ``` ### Build fails with exit 1 Run after a green build, so one file is rewritten. loclizr build ```txt error LZ3004 arg-missing locales/de.json:3:18 de cart.greeting The en text uses {name} and the de translation does not. fix add {name} to "cart.greeting" in locales/de.json error LZ3005 arg-extra locales/de.json:3:18 de cart.greeting The de translation uses {nmae}, which the en text does not have, so this locale renders en text instead. fix check the spelling of {nmae} in locales/de.json, or add it to the en text wrote src/loclizr (1 file) and locales/loclizr.context.json commit locales/loclizr.context.json; `loclizr check` compares it 2 messages, 2 locales (source en), 2 errors, 0 warnings fell back to source text: de 1 ``` Header: severity, code, rule, `file:line:column`, locale, key. One typo, two errors: the compiler does not guess which was meant. | Error | Caught | Effect | | -------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | | `LZ3004 arg-missing` | German lost `{name}`. | Exit 1. Alone, it keeps the German arm, rendered without the name. | | `LZ3005 arg-extra` | German uses `{nmae}`, which `args` never carries. | Dropped the German arm: `args.nmae` would print `undefined`. | ### What ships meanwhile Only the `default` arm is left and serves German; `fell back to source text: de 1` counts it. src/loclizr/messages/cart.js ```js export function cart_greeting(args, opts) { switch ($l(opts)) { default: return `Hi ${args.name}, your cart is ready` } } ``` ```ts m.cart_greeting({ name: 'Ada' }, { locale: 'de' }) // "Hi Ada, your cart is ready" m.cart_items({ count: 3 }, { locale: 'de' }) // "3 Artikel im Warenkorb" ``` German visitors get English for `cart.greeting`; the untouched `cart.items` stays German. ### Fix and rebuild Put `{name}` back. loclizr build ```txt wrote src/loclizr (1 file) and locales/loclizr.context.json commit locales/loclizr.context.json; `loclizr check` compares it 2 messages, 2 locales (source en), 0 errors, 0 warnings ``` src/loclizr/messages/cart.js ```js case 'de': return `Hallo ${args.name}, dein Warenkorb ist bereit` ``` The tree is written even on error, so the app keeps typechecking and real errors are not buried under “cannot find module”. The exit code gates: | Command | Exit on error | Use | | ------------------------- | ------------------- | ----------------------------------------------------------------------------------------------- | | `loclizr build` | 1 | `prebuild`, local gate | | `loclizr build --no-fail` | 0, same diagnostics | dev server | | `loclizr check` | 1, writes nothing | CI. The same two errors, plus `LZ5002 output-stale` for `cart.js` if the green tree is on disk. | A target value that fails to lower, or uses an argument the source lacks, takes the fallback chain. A source value that fails to parse gets no function: see [fatal scope](https://yasinmiran.github.io/loclizr/reference/checks/#fatal-scope). ## Where to go next [Language switching](https://yasinmiran.github.io/loclizr/guides/language-switching/)setLocale, cookie persistence, and what re-renders in React. [Server rendering](https://yasinmiran.github.io/loclizr/guides/server-rendering/)Request-scoped locale through AsyncLocalStorage, cookie first, then Accept-Language. [Importing i18next catalogs](https://yasinmiran.github.io/loclizr/guides/i18next-import/)What converts, what does not, and the diagnostics that say so. [Comparison](https://yasinmiran.github.io/loclizr/project/comparison/)i18next, next-intl, Paraglide, Lingui, FormatJS and Intlayer, and when to pick them instead. [Before you adopt](https://yasinmiran.github.io/loclizr/project/limits/)Limits, security, versions and continuity. ## What it deliberately does not do * **No per-locale delivery.** Every locale is inlined into each used message function, so bundle size grows with locale count. Example app, 26 messages in 3 locales, `vite build`: tree plus runtime 7.0 kB minified (2.6 kB gzipped); tree alone, `loclizr` external, 4.7 kB (1.6 kB); runtime alone 2.6 kB (1.2 kB). * **No runtime `format()`.** ICU is parsed at build time; the browser ships no parser, and content unknown at build time has no formatter. * **No extraction.** Nothing finds or rewrites hardcoded strings. Catalogs are authored or imported. * **Servers: Web Fetch handlers.** Express and Fastify via `localeFromHeaders`. Next.js is not first-class in v0.1. * **No URL-prefix locale routing.** Locale comes from a cookie or `Accept-Language`. * **No `--watch`.** `loclizr build --no-fail` in `predev`, or the example’s inline Vite plugin. * **Translations are a build input.** A copy fix is a commit and a deploy. To change copy in production without a release, use i18next with a backend. * **`0.x`.** A minor release may break. Day zero: no users yet, no track record, no maintenance promise. MIT, with the [maintenance situation written down](https://yasinmiran.github.io/loclizr/project/continuity/). # Astro > Prerendered pages per locale, or on-demand pages that follow Accept-Language, through one middleware. An Astro app that prerenders `/de/` and `/en/`, then a variant that picks the language per request. There is no Astro entry: a middleware wraps each render in `runWithLocale` from `loclizr/server`. 1. ### Create the app ```bash npm create astro@latest app -- --template minimal --install --no-git --yes cd app npm i loclizr npm i -D @astrojs/check typescript npx loclizr init ``` `init` seeds `locales/en.json`. Add the German catalog beside it: locales/de.json ```json { "nav": { "home": "Startseite", "cart": "Warenkorb" }, "cart": { "greeting": "Hallo {name}, dein Warenkorb ist bereit", "items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel in deinem Warenkorb} other {# Artikel in deinem Warenkorb}}" } } ``` 2. ### Add the scripts package.json ```json "scripts": { "predev": "loclizr build --no-fail", "dev": "astro dev", "prebuild": "loclizr build", "build": "astro build", "preview": "astro preview", "pretypecheck": "loclizr build", "typecheck": "astro check", "astro": "astro" }, ``` The three hooks `init` printed, plus `typecheck` running `astro check`. 3. ### Add a route per locale src/pages/\[lang]/index.astro ```astro --- import * as m from '../../loclizr/messages' import CartBadge from '../../components/CartBadge.astro' export function getStaticPaths() { return m.locales.map((lang) => ({ params: { lang } })) } --- {m.nav_cart()}

{m.cart_greeting({ name: 'Ada' })}

``` src/components/CartBadge.astro ```astro --- import * as m from '../loclizr/messages' const { count } = Astro.props ---

{m.cart_items({ count })}

``` 4. ### Scope each render with middleware src/middleware.ts ```ts import { defineMiddleware } from 'astro:middleware' import { runWithLocale } from 'loclizr/server' export const onRequest = defineMiddleware((context, next) => { const { lang } = context.params return lang === undefined ? next() : runWithLocale(lang, next) }) ``` Middleware runs for every prerendered page at build time, and its scope covers each child component. `npm run build` writes: dist/de/index.html ```html Warenkorb

Hallo Ada, dein Warenkorb ist bereit

3 Artikel in deinem Warenkorb

``` dist/en/index.html ```html Cart

Hi Ada, your cart is ready

3 items in your cart

``` Caution Passing `{ locale: Astro.params.lang }` per call instead also typechecks, but a call that misses it renders the source language and the build still passes. Caution Never call `setLocale` in frontmatter: outside a scope it sets one process-wide locale that pages built in parallel share, and inside the middleware it throws. ## On-demand rendering This replaces the static setup: one page at `/` picks the language per request. Step 2 replaces `src/middleware.ts` and step 3 deletes `src/pages/[lang]/`. 1. ### Add the Node adapter ```bash npx astro add node --yes ``` `astro add` writes the adapter; add `output: 'server'` yourself. astro.config.mjs ```js // @ts-check import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; // https://astro.build/config export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }) }); ``` 2. ### Replace the middleware src/middleware.ts ```ts import { defineMiddleware } from 'astro:middleware' import { localeFromRequest, runWithLocale } from 'loclizr/server' import * as m from './loclizr/messages' const options = { locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie } export const onRequest = defineMiddleware(async (context, next) => { const locale = localeFromRequest(context.request, options) const response = await runWithLocale(locale, next) response.headers.set('Content-Language', locale) response.headers.append('Vary', 'Accept-Language, Cookie') return response }) ``` `localeFromRequest` reads the configured cookie (`m.cookie`), then `Accept-Language`, then the source locale. 3. ### Serve the page from `/` Delete `src/pages/[lang]/`, then replace `src/pages/index.astro` with the same page minus `getStaticPaths`: src/pages/index.astro ```astro --- import * as m from '../loclizr/messages' import CartBadge from '../components/CartBadge.astro' --- {m.nav_cart()}

{m.cart_greeting({ name: 'Ada' })}

``` 4. ### Build and run ```bash npm run build PORT=8080 node dist/server/entry.mjs ``` `PORT` overrides the adapter’s default of 4321. curl -si -H 'Accept-Language: de-CH,de;q=0.9' http\://localhost:8080/ ```txt HTTP/1.1 200 OK content-language: de content-type: text/html vary: Accept-Language, Cookie Date: Sat, 10 Oct 2026 18:51:04 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked Warenkorb

Hallo Ada, dein Warenkorb ist bereit

3 Artikel in deinem Warenkorb

``` curl -si -H 'Accept-Language: en-US' -H 'Cookie: locale=de' http\://localhost:8080/ ```txt HTTP/1.1 200 OK content-language: de content-type: text/html vary: Accept-Language, Cookie Date: Sat, 10 Oct 2026 18:51:05 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked Warenkorb

Hallo Ada, dein Warenkorb ist bereit

3 Artikel in deinem Warenkorb

``` The cookie beats `Accept-Language`. Keep localized responses out of shared caches unless the cache keys on the locale cookie, as [Server rendering](https://yasinmiran.github.io/loclizr/guides/server-rendering/) explains. ## Islands Wire the island like any client app: [React](https://yasinmiran.github.io/loclizr/guides/react/), [Svelte](https://yasinmiran.github.io/loclizr/guides/svelte/), [Vue](https://yasinmiran.github.io/loclizr/guides/vue/) or [Solid](https://yasinmiran.github.io/loclizr/guides/solid/). A server-rendered Vue island must not subscribe during the server render, or each render leaves a listener on the process-wide store; the [Vue composable](https://yasinmiran.github.io/loclizr/guides/vue/#write-the-composable) subscribes in `onMounted`, which runs only in the browser. With on-demand rendering, render `` from `m.getLocale()`, as above. The client store reads the cookie, then ``, and the server negotiated from the same cookie or `Accept-Language`, so an island hydrates in the server’s language. On the static `[lang]` route the URL picks the language, but the client store still reads the cookie before ``. Once a visitor has the cookie: * An island on `/en/` hydrates in the cookie’s language, and React reports a hydration mismatch. * The store rewrites `` to the cookie’s language. * A `setLocale` switcher updates only the islands; Astro-rendered text stays in the URL’s language. The locale comes from the cookie by design ([Limits](https://yasinmiran.github.io/loclizr/project/limits/#the-locale-never-comes-from-the-url)). On this route, switch language by linking to `/{tag}/`, and sync the cookie to the URL’s locale before any island hydrates: src/pages/\[lang]/index.astro ```astro {m.nav_cart()} ``` With it, `/en/` hydrates in English whatever cookie the visitor brought, and `/de/` writes `de`. # Catalogs and ICU messages > One JSON file per locale, ICU MessageFormat inside it. One file per locale at `locales/{locale}.json`. The compiler reads it and never writes it. locales/en.json ```json { "nav": { "home": "Home", "cart": "Cart" }, "cart": { "greeting": "Hi {name}, your cart is ready", "items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}", "total": "Total: {amount, number, ::currency/USD}", "updated": "Updated {at, date, medium}" }, "order": { "status": "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}" }, "terms": { "accept": "Read our terms before you continue." } } ``` | Value | Result | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Object | A level, unless it is a `formatjs extract` record: a string `defaultMessage` with only extract fields beside it is `LZ1010 catalog-shape-invalid` ([Migrating from FormatJS](https://yasinmiran.github.io/loclizr/guides/formatjs-migration/#what-the-build-accepts)). | | String | A message: `nav.home` becomes `m.nav_home()` in module `nav`. | | Dotted key, `"nav.home"` | Same path as the nested form; both together are `LZ1011 duplicate-key`. | | `null` | Missing translation; takes the fallback chain. In the source locale it drops the message and is `LZ1010 catalog-shape-invalid`, since the source has nothing to fall back to; a target still carrying the key gets `LZ3003 extra-translation`. | | Array | `LZ1010 catalog-shape-invalid`: flattening would yield one message per item. | A file with `{{` anywhere, or a key with a CLDR plural suffix, is i18next, converted in memory ([Importing i18next catalogs](https://yasinmiran.github.io/loclizr/guides/i18next-import/)). The rest is ICU. ## Arguments | Written | Argument type | | ---------------------------------------------------------- | ------------------------------------------------ | | `{name}` | `string \| number` | | `{count, plural, ...}` | `number` | | `{at, date, ...}` | `Date \| number` | | `{state, select, packing {...} shipped {...} other {...}}` | `'packing' \| 'shipped'`, branches minus `other` | src/loclizr/messages/cart.d.ts ```ts /** en: "Hi {name}, your cart is ready" */ export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): string /** en: "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" */ export declare function cart_items(args: { count: number }, opts?: MessageOptions): string /** en: "Updated {at, date, medium}" */ export declare function cart_updated(args: { at: Date | number }, opts?: MessageOptions): string ``` ## Plural and selectordinal locales/en.json ```json { "guests": "{count, plural, offset:1 =0 {Nobody is coming} =1 {Only you} one {You and # other} other {You and # others}}", "rank": "{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place" } ``` src/loclizr/messages/\_root.js (en arm) ```js if (n0 === 0) return `Nobody is coming` if (n0 === 1) return `Only you` switch ($plural1('en', n0 - 1, false)) { case 'one': return `You and ${$number1(l, n0 - 1, $f44136fa355b3678a)} other` default: return `You and ${$number1(l, n0 - 1, $f44136fa355b3678a)} others` } ``` 1. Exact branches (`=0`, `=1`) match first, against the value as passed. 2. `Intl.PluralRules` picks a category in the arm’s language (the literal `'en'`), from the value minus any `offset`. 3. `#` renders that value in the visitor’s locale (`l`). A locale falling back to the source gets English branches and categories, with its own digits. | Rule | Severity | Cause | | ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------- | | `LZ2004` | error | A plural without `other`: the message is dropped, not guessed at. | | `LZ2005` | error | A select without `other`, dropped the same way. | | `LZ3013 plural-category-unreachable` | warn | A branch the locale never selects: German `zero` passes syntax checks but never renders. Use `=0`. | | `LZ3007 plural-category-incomplete` | warn | A category the locale selects and the message lacks (`many`, below). | ### The `many` category ```txt warn LZ3007 plural-category-incomplete locales/es.json:1:23 es cart.items es selects many for some values of {count}, and this plural has no branch for it. fix add many {...} to {count, plural, ...} in locales/es.json ``` Spanish, French, Italian and Portuguese select `many` for millions in current CLDR (Node 22: `new Intl.PluralRules('es').resolvedOptions().pluralCategories` is `['many', 'one', 'other']`). Each plural with only `one` and `other` warns once per such locale. * Add `many {# ...}` mirroring `other`. * Or set `'plural-category-incomplete': 'off'` until the catalog is clean, then turn it back on. * The i18next importer adds no `many`: `items_one` beside `items_other` warns the same way. Add `items_many` to clear it. ## Select locales/en.json ```json { "order": { "status": "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}" } } ``` Derive the type from the signature: `type Delivery = Parameters[0]['state']`. A translation dropping a branch warns (`LZ3008`); adding one the source lacks is an error (`LZ3009`). ## Number and date styles | Written | Intl options | | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `{n, number}` | `{}` | | `{n, number, integer}` | `{ maximumFractionDigits: 0 }` | | `{ratio, number, percent}` | `{ style: 'percent' }` | | `{amount, number, ::currency/USD}` | the skeleton, resolved at build time | | `{views, number, ::compact-short}` | `{ notation: 'compact', compactDisplay: 'short' }` | | `{bytes, number, ::scale/1000}` | `{}`, with the value multiplied by 1000 first: Intl has no scale option | | `{speed, number, ::unit/kilometer-per-hour}` | `{ style: 'unit', unit: 'kilometer-per-hour' }`; 90 renders `90 km/h` | | `{speed, number, ::measure-unit/length-meter per-measure-unit/duration-second}` | `{ style: 'unit', unit: 'meter-per-second' }`; 5 renders `5 m/s` | | `{at, date}` or `{at, date, medium}` | `{ dateStyle: 'medium' }` | | `{when, time, short}` | `{ timeStyle: 'short' }` | | `{when, date, ::yMMMd}` | `{ year: 'numeric', month: 'short', day: 'numeric' }` | | `{when, time, ::jm}` | `{ hour: 'numeric', minute: 'numeric' }`; the locale picks the hour cycle, so `en` renders `2:05 PM` and `de` `14:05` | Under `percent`, Intl already multiplies by 100, so `::percent scale/100` renders 0.25 as `25%`, the same as `::percent`. A bare `{amount, number, currency}` fails: ICU carries no currency code. ```text error LZ2002 icu-style-unknown locales/en.json:14:11 en usd "currency" is not a known number style. fix ICU carries no currency code. Define formats.number.currency, or write ::currency/USD. ``` Any other style name, like `eur`, must be defined in `formats.number` or `formats.dateTime` in `loclizr.config.ts`. ## Markup `terms` makes `link` a required argument; the message returns an array, not a string. src/loclizr/messages/terms.d.ts ```ts /** en: "Read our terms before you continue." */ export declare function terms_accept( args: { link: (chunks: readonly (string | T)[]) => T }, opts?: MessageOptions, ): readonly (string | T)[] ``` * Tags nest: `full terms` compiles to `args.b([args.i(['full']), ' terms'])`. * Translators may renest freely; only tag names are compared across locales (`LZ3010`). * Tag attributes are not supported. * `Parts` from `loclizr/react` renders the array ([React](https://yasinmiran.github.io/loclizr/guides/react/)). ## Apostrophes Quoting starts only when an apostrophe precedes `{`, `}`, `<`, or `#` inside a plural branch. | Written | Renders | | ------- | ---------------------------------- | | `Don't` | `Don't` | | `'{'` | `{` | | `''` | `'`, always one literal apostrophe | The record stores the canonical quoted form: `Don't` is recorded as `Don''t`. ## Descriptions and placeholder notes Translator notes go in `locales/en.meta.json`, never in `en.json`: a TMS (translation management system) imports every leaf there as a string to translate, so a note would ship as copy ([Translation workflow](https://yasinmiran.github.io/loclizr/guides/translation-workflow/)). locales/en.meta.json ```json { "cart.items": { "description": "Badge under the cart icon on every page.", "placeholders": { "count": "Number of line items, not total quantity." } }, "order.status": { "description": "Chip in the order list. Past tense." } } ``` Both fields land in the context record. A note for a key that no longer exists is `LZ1015`, and a placeholder note for an argument the message does not take is `LZ1022`. `LZ3012 ambiguous-source` fires when keys share source text and one lacks a description: ```text error LZ3012 ambiguous-source locales/en.json:5:14 2 keys share the source text "Cart" and one has no description. A translator sees one string with no way to tell the meanings apart. app.cart locales/en.json:5:14 no description nav.cart locales/en.json:21:14 "Top navigation link that opens the cart page." fix add descriptions in locales/en.meta.json: "app.cart": { "description": "" } or turn the rule down in loclizr.config.ts: severity: { 'ambiguous-source': 'warn' } ``` # Continuous integration > Put loclizr check in the pipeline; build stays in the scripts. `loclizr init` prints three scripts and one CI step. The scripts build; CI checks. 1. ### Add the scripts package.json ```json "predev": "loclizr build --no-fail", "prebuild": "loclizr build", "pretypecheck": "loclizr build" ``` 2. ### Add the gate Run `check` before any step that runs `loclizr build`. .github/workflows/ci.yml ```yaml # loclizr check compares the committed context record against this tree. # loclizr build would rewrite that record in the CI workspace and pass. - run: npx loclizr check ``` | Hook | Runs | Why | | -------------------------- | ----------------- | ------------------------------------------------------------------- | | `predev` | `build --no-fail` | A tree has to exist; one untranslated key must not stop `vite dev`. | | `prebuild`, `pretypecheck` | `build` | Fails on an error. | | CI | `check` | Compares the committed record instead of rewriting it. | ## A complete workflow Caution No script `init` prints runs on install. Do not add a `prepare` hook that runs `loclizr build`: `npm ci` runs `prepare`, which would rewrite the record before `check` compares it. .github/workflows/loclizr.yml ```yaml on: [push, pull_request] jobs: loclizr: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 # No loclizr script runs on install; see the note above. - run: npm ci # The gate. Only this step is loclizr's; the rest prove the tree against the app. - run: npx loclizr check # prebuild regenerates the tree before the app builds. - run: npm run build - run: npm run typecheck - run: npm test ``` The last two steps are the app’s own. They assume a `typecheck` script (`tsc -b` for the Vite template, as in the [Quickstart](https://yasinmiran.github.io/loclizr/start/quickstart/)) and a `test` script ([Testing](https://yasinmiran.github.io/loclizr/guides/testing/)); drop either step the app does not have. Transcripts come from `examples/vite-react` (26 messages, 3 locales), except the out-of-memory one under [Memory on small runners](#memory-on-small-runners). The two warning transcripts drop its `'group-args-heterogeneous': 'off'` line from `loclizr.config.ts`, so that rule warns. ## `build --no-fail` Changes the exit code and nothing else: every diagnostic is still printed and counted. | Run | Exit | | ---------------------- | ------------------------------------------- | | reached the write step | 0 instead of 1 | | wrote no output | still 1: the next command would fail anyway | | could not run | still 2 | `check` refuses the flag. [CLI](https://yasinmiran.github.io/loclizr/reference/cli/#--no-fail) shows both runs. ## What check does Everything `build` does, with no writes, plus two rules. | Rule | Fires when | | --------------------- | ------------------------------------------------------------------------------------- | | `LZ5002 output-stale` | A generated file on disk differs from what this run would write. | | `LZ5003 record-stale` | The committed `locales/loclizr.context.json` differs from the one this tree produces. | To commit the generated tree, delete the self-ignoring `.gitignore` inside `outDir`. That choice decides what `LZ5002` gates: | Generated tree | What CI gates | A translator’s pull request | | -------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | ignored, the default | Catalogs and record. `check` runs before the tree exists, so `LZ5002` has nothing to compare and never fires in CI. | Passes `check`. | | committed | The tree too: an edit to `de.json` changes `messages/nav.js` on disk. | Fails `check` until someone reruns `build` and commits the result. | With the tree ignored, `pretypecheck` rebuilds a stale local tree before `tsc` runs instead of reporting it. A fresh clone gives `LZ5002` nothing to compare, so `check` needs no prior `build`: ```text 26 messages, 3 locales (source en), 0 errors, 0 warnings ``` ## The committed record The record is a pure function of catalogs, meta sidecar, scanned sources and config; it never reads its previous value. Editing a source string changes that message’s `source` and `sourceHash`, so `check` fails until `build` reruns and the record is committed with it: ```text error LZ5003 record-stale locales/loclizr.context.json `locales/loclizr.context.json` no longer matches the catalogs: the contract this build derived differs from the committed record. fix run `loclizr build` and commit the record with the string change. ``` `build` runs the same comparison at `warn`, so a `prebuild` that quietly rewrote the record still says so: ```text warn LZ5007 record-rewritten locales/loclizr.context.json `locales/loclizr.context.json` was rewritten: the committed record's contract differs from the one these catalogs produce. fix commit the rewritten record with the string change, so the context lands in the same pull request. ``` ### What the gate compares The contract: keys, ids, modules, kinds, source text and hash, descriptions, argument names and types, plural and select shapes, markup tags. Two fields are projected out of both sides first: | Field | Why | Tried on the example, tree absent | | ---------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `translations`, per-locale status | A translation batch is not a contract change. | German string edited in `de.json`: no `LZ5003` (with the tree on disk, `LZ5002`). German key deleted: no `LZ5003` either, but `LZ3001 missing-translation` for the two locales that fall back, the right error. | | `usage`, file plus enclosing scope | Outside the gate; the next `build` rewrites them. | Component calling `m.nav_home()` renamed, file moved: `check` exits 0. | `build` still rewrites the whole record on any byte change; only the gate ignores these fields. [Translation workflow](https://yasinmiran.github.io/loclizr/guides/translation-workflow/) follows a translation batch to the merge. ## Warnings, and machine output | Flag | Effect | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--max-warnings` | No cap by default, so warnings alone never fail. `--max-warnings 0` makes the warning below exit 1 on `check`; `build --no-fail` prints the same and still exits 0. | | `--quiet` | Errors only, plus a summary line when warnings were dropped. | | `--reporter json` | One object: diagnostics sorted by severity, code, file, locale, key and offset, summary last. | ```text warn LZ4006 group-args-heterogeneous The group "errors" has members with different argument sets, so every dynamic call site must pass the union: seconds. errors.forbidden takes no arguments errors.not_found takes no arguments errors.rate_limited takes seconds fix Split the group by argument shape, or give the odd members bare {x} arguments. 26 messages, 3 locales (source en), 0 errors, 1 warning ``` That is `check --max-warnings 0`, exit 1. The same run through `--reporter json`: ```json { "schema": 1, "diagnostics": [ { "code": "LZ4006", "rule": "group-args-heterogeneous", "severity": "warn", "fatal": false, "message": "The group \"errors\" has members with different argument sets, so every dynamic call site must pass the union: seconds.", "hint": "Split the group by argument shape, or give the odd members bare {x} arguments.", "file": null, "locale": null, "key": null, "span": null, "related": [ { "file": null, "locale": null, "key": "errors.forbidden", "span": null, "message": "takes no arguments" }, { "file": null, "locale": null, "key": "errors.not_found", "span": null, "message": "takes no arguments" }, { "file": null, "locale": null, "key": "errors.rate_limited", "span": null, "message": "takes seconds" } ] } ], "summary": { "errors": 0, "warnings": 1, "messages": 26, "locales": 3, "fellBack": [] } } ``` `file` is always POSIX and relative to the project root, so sort order matches on every machine. ## Exit codes | Code | Meaning | | ---- | ----------------------------------------------------------------------------------------- | | 0 | clean, or warnings only and at or under `--max-warnings` | | 1 | at least one error, or warnings over `--max-warnings` | | 2 | the tool could not run: invalid usage, invalid config, unsafe `outDir`, unwritable output | Exit 134 is not loclizr’s: it is Node running out of heap, see [Memory on small runners](#memory-on-small-runners). ## Memory on small runners `check` and `build` need a heap that grows with messages times locales, from 64 MB at 1000 x 10 to 704 MB at 10000 x 30; [Honest limits](https://yasinmiran.github.io/loclizr/project/limits/#build-memory-grows-with-the-catalog) has the table. Those are the smallest heaps that passed, with no margin. Node gives a container about half its memory limit as heap, so a container needs more than twice the smallest heap to run with no flags: the 10000 x 30 catalog passes in 1.5 GB. On a smaller one, set the heap once for the job. `prebuild` and `pretypecheck` both run `loclizr build`, so `check`, `build` and `typecheck` all need it: .github/workflows/loclizr.yml ```yaml jobs: loclizr: runs-on: ubuntu-latest env: NODE_OPTIONS: --max-old-space-size=768 # steps as in the complete workflow above ``` Keep the value above the table’s smallest heap and below the container’s memory. 768 is what the 10000 x 30 catalog passed with in a 1 GB container. The flag is a ceiling, not a reservation: the kernel steps in only when the process outgrows the container, so `--max-old-space-size=1024` still passes in that 1 GB container, while in a 512 MB container the same catalog ends in `Killed`, exit 137. Container figures come from `node:22-slim` with Node 22.23 on arm64; GitHub’s `ubuntu-latest` runners are x64. Without the flag, that 1 GB container stops `check` here (the `----- Native stack trace -----` section and the shell’s `Aborted (core dumped)` line left out): ```text <--- Last few GCs ---> [10:0xfa11d4760000] 9765 ms: Mark-Compact 502.7 (520.6) -> 498.9 (520.9) MB, pooled: 2 MB, 82.61 / 0.10 ms (average mu = 0.064, current mu = 0.053) allocation failure; scavenge might not succeed [10:0xfa11d4760000] 9871 ms: Mark-Compact 502.8 (520.9) -> 499.0 (520.6) MB, pooled: 2 MB, 100.49 / 0.09 ms (average mu = 0.059, current mu = 0.052) allocation failure; scavenge might not succeed <--- JS stacktrace ---> FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory ``` Some runs print `Reached heap limit Allocation failed - JavaScript heap out of memory` instead. Either way the exit code is 134: Node aborts before any handler runs, so loclizr prints no diagnostic and no summary line. # Dynamic keys > A typed lookup for a key chosen at run time from a known set. loclizr.config.ts ```ts import { defineConfig } from 'loclizr' export default defineConfig({ locales: ['en', 'de', 'de-AT'], sourceLocale: 'en', groups: { errors: 'errors' }, }) ``` Looking up an API error code as `m['errors_' + code]` loses the types and hides the call from the usage scan. A group gives a typed record and key union. 1. ### Declare a prefix Map a group name to a key prefix under `groups`, as above. It matches on a dot boundary: `errors` captures `errors.forbidden`, never `errorsforbidden`. 2. ### Build `loclizr build` writes `groups.js` and `groups.d.ts` beside the barrel. 3. ### Index the record src/Problems.tsx ```tsx import { errors, type ErrorsKey } from './loclizr/groups' const problems: readonly ErrorsKey[] = ['forbidden', 'not_found', 'rate_limited'] export function Problems({ seconds }: { seconds: number }) { return (
    {problems.map((code) => (
  • {errors[code]({ seconds })}
  • ))}
) } ``` ## What is generated src/loclizr/groups.js ```js import { errors_forbidden, errors_not_found, errors_rate_limited } from './messages/errors.js' export const errors = /*#__PURE__*/ Object.freeze({ __proto__: null, forbidden: errors_forbidden, not_found: errors_not_found, rate_limited: errors_rate_limited, }) ``` src/loclizr/groups.d.ts ```ts export type ErrorsKey = 'forbidden' | 'not_found' | 'rate_limited' export interface ErrorsArgs { forbidden: EmptyArgs not_found: EmptyArgs rate_limited: { seconds: number } } export declare const errors: Readonly<{ [K in ErrorsKey]: (args: ErrorsArgs[K], opts?: MessageOptions) => string }> ``` * Each member is the key minus prefix and dot. * Null prototype: a member named `constructor` cannot reach `Object.prototype`. * Type names are the group name in PascalCase plus `Key` and `Args`: `nav.main` gives `NavMainKey`. ## Typing | Key | Typing | | ----------------------------------- | ---------------------------------------------------------- | | Literal, `errors.rate_limited(...)` | Exact: `{ seconds: 30 }` compiles, `{ nope: 1 }` does not. | | Union, `errors[code](...)` | Every argument any member takes is required. | Calling a union of signatures intersects their parameters, so `errors[code]()` and `errors[code]({})` both fail on `seconds`. Sound, but one new member taking `{ retryAt }` breaks every existing dynamic call site. ## Mixed argument sets Members that disagree warn. A group that mixes them on purpose turns the rule off, as the example app does: loclizr.config.ts ```ts import { defineConfig } from 'loclizr' export default defineConfig({ locales: ['en', 'de', 'de-AT'], sourceLocale: 'en', groups: { errors: 'errors' }, severity: { 'group-args-heterogeneous': 'off' }, }) ``` With the rule on, the same build prints: ```text warn LZ4006 group-args-heterogeneous The group "errors" has members with different argument sets, so every dynamic call site must pass the union: seconds. errors.forbidden takes no arguments errors.not_found takes no arguments errors.rate_limited takes seconds fix Split the group by argument shape, or give the odd members bare {x} arguments. ``` ## An empty group ```text error LZ4004 group-empty The group "nope" matched no keys under the prefix "nope". fix A prefix matches on a dot boundary, so "nope" captures "nope.forbidden" and never "nopeforbidden". Check the prefix. ``` A prefix that matches nothing is an error. The build still emits `NopeKey = never`, so every call through it fails typecheck too. ## Tree-shaking * Using a group retains all its members; an untouched group drops like any unused export. * The barrel does not re-export `groups.js`: only an app importing it pays. Messages called by name stay individually tree-shaken. * The usage scan counts `errors.forbidden(...)` for that message and `errors[code](...)` for every member, so a message reached only dynamically never reads as unused. ## Content unknown at build time A CMS title or user-typed text is not a message and belongs outside the catalog. A runtime `format()` for it would ship an ICU parser to the browser; it is deferred ([Limits](https://yasinmiran.github.io/loclizr/project/limits/)). # Migrating from FormatJS > Compile react-intl messages to plain ICU catalogs, carry each description into the meta sidecar, then move call sites one at a time. loclizr.config.ts ```ts import { defineConfig } from 'loclizr' export default defineConfig({ locales: ['de', 'en'], sourceLocale: 'en', formats: { number: { usd: { style: 'currency', currency: 'USD' } }, }, }) ``` FormatJS and loclizr read the same ICU MessageFormat, so the messages carry over as written. What changes is where they live. react-intl keeps each `defaultMessage` in the code and extracts it; loclizr has no extractor, so after the move `locales/en.json` is the source and the code calls a generated function. FormatJS keeps the fuller runtime: it formats messages unknown at build time, and `eslint-plugin-formatjs` checks the code side. loclizr checks the catalogs inside the build and types every call from them. Every block here ran with `@formatjs/cli` 6.16, react-intl 12.1, React 19.3 and TypeScript 7.0, against a cart with five messages. | react-intl | loclizr | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `defaultMessage` in code, extracted | `locales/en.json`, edited by hand once the code moves | | `description` | `description` in `locales/en.meta.json`, a string | | Explicit `id`, `cart.greeting` | The catalog key: `m.cart_greeting()` in module `cart` | | Generated hash `id` | A key like `rM52go`, exported as `rM52go` ([below](#hash-ids)) | | `IntlProvider` `locale` | `setLocale` and `useLocale`, persisted in the `locale` cookie | | `IntlProvider` `messages` | Compiled into the generated tree at build time | | `IntlProvider` `formats.number` | `formats.number` in the config | | `formats.date` and `formats.time` | One `formats.dateTime` map, read by `date` and `time` arguments alike | | `IntlProvider` `timeZone` | `formats.timeZone` | | `values={{ link: (chunks) => ... }}` | The same handler as an argument; the call returns an array that `Parts` renders | | `defaultRichTextElements` | None: every tag is a required handler at its call site | | A React element as a plain value | Not accepted: a plain argument is `string \| number` ([below](#rich-text)) | | `onError` for a missing translation, at runtime | `LZ3001 missing-translation`, an error at build time; the message falls back to source text | | `FormattedNumber`, `FormattedDate`, `intl.formatNumber` | No counterpart: put the value in a message, or call `Intl` | ## Migrate an app 1. ### Give every message an explicit id ```tsx ``` The id becomes the catalog key and the key becomes the function name, so `cart.checkout` is `m.cart_checkout()`. A message without one gets a hash, which makes a poor function name ([Hash ids](#hash-ids)). Rename the key in each translated extract file too, or the build reports the old key as `LZ3003` and the new one as `LZ3001`. 2. ### Extract, then compile to plain strings ```bash npx formatjs extract 'src/**/*.tsx' --out-file lang/en.json npx formatjs compile lang/en.json --out-file locales/en.json npx formatjs compile lang/de.json --out-file locales/de.json ``` `lang/de.json` is the translated extract file. `formatjs compile --format` reads a TMS export (`transifex`, `smartling`, `lokalise`, `crowdin`) into the same shape. Feed loclizr the compiled output, never the extract file and never `--ast` output ([What the build accepts](#what-the-build-accepts)): locales/en.json ```json { "cart.checkout": "Check out", "cart.greeting": "Hi {name}, your cart is ready", "cart.items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}", "cart.total": "Total: {amount, number, usd}", "terms.accept": "Read our terms before you continue." } ``` Dotted keys are the same path as nested ones, so `cart.greeting` lands in module `cart`. 3. ### Carry the descriptions into the meta sidecar scripts/meta.mjs ```js import { readFileSync, writeFileSync } from 'node:fs' const extracted = JSON.parse(readFileSync('lang/en.json', 'utf8')) const meta = {} for (const [id, { description }] of Object.entries(extracted)) { if (description === undefined) continue meta[id] = { description: typeof description === 'string' ? description : JSON.stringify(description) } } writeFileSync('locales/en.meta.json', `${JSON.stringify(meta, null, 2)}\n`) ``` locales/en.meta.json ```json { "cart.checkout": { "description": "Checkout button." }, "cart.greeting": { "description": "Heading on the cart page." }, "cart.items": { "description": "{\"context\":\"Badge under the cart icon\",\"maxLength\":30}" }, "cart.total": { "description": "Order total under the item list." }, "terms.accept": { "description": "Under the checkout button. The link opens the terms page." } } ``` react-intl accepts an object as a description; the sidecar takes a string, and an object there is `LZ1010 catalog-shape-invalid`. The snippet stringifies it; rewrite it as a sentence when you can. Each description lands in the [context record](https://yasinmiran.github.io/loclizr/reference/context-record/), and keys that share source text but carry descriptions pass `LZ3012 ambiguous-source` ([Catalogs](https://yasinmiran.github.io/loclizr/guides/catalogs/#descriptions-and-placeholder-notes)). 4. ### Move `formats` into the config Each named style on `IntlProvider` goes into `loclizr.config.ts` under the same name: the config at the top of this page carries `usd` for `{amount, number, usd}`. Without it the build exits 1 with an error per locale: ```text error LZ2002 icu-style-unknown locales/en.json:5:25 en cart.total "usd" is not a known number style. fix Define formats.number.usd in loclizr.config.ts, or use an ICU skeleton. ``` Date and time styles share `formats.dateTime`, so a `date` style and a `time` style need distinct names. Option sets are checked against `Intl` when the config loads ([Configuration](https://yasinmiran.github.io/loclizr/reference/configuration/#formats)). 5. ### Build npx loclizr build ```txt warn LZ5004 scan-found-nothing The scan read 1 file and found no import of the generated messages. fix check scan.include in loclizr.config.ts. A usage site is found through an import such as import * as m from './loclizr/messages' wrote src/loclizr (11 files) and locales/loclizr.context.json commit locales/loclizr.context.json; `loclizr check` compares it 5 messages, 2 locales (source en), 0 errors, 1 warning ``` `LZ5004` warns until the first call site imports the tree. src/loclizr/messages/cart.d.ts ```ts // @generated by loclizr abi=1. Do not edit; run `loclizr build`. import type { EmptyArgs, MessageOptions } from 'loclizr' /** en: "Check out" */ export declare function cart_checkout(args?: EmptyArgs, opts?: MessageOptions): string /** en: "Hi {name}, your cart is ready" */ export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): string /** en: "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" */ export declare function cart_items(args: { count: number }, opts?: MessageOptions): string /** en: "Total: {amount, number, usd}" */ export declare function cart_total(args: { amount: number }, opts?: MessageOptions): string ``` 6. ### Swap call sites src/Cart.tsx (react-intl) ```tsx import { FormattedMessage, defineMessages, useIntl } from 'react-intl' const messages = defineMessages<{ greeting: { name: string }; items: { count: number } }>({ greeting: { id: 'cart.greeting', defaultMessage: 'Hi {name}, your cart is ready', description: 'Heading on the cart page.', }, items: { id: 'cart.items', defaultMessage: '{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}', description: { context: 'Badge under the cart icon', maxLength: 30 }, }, }) export function Cart({ name, count, total }: { name: string; count: number; total: number }) { const intl = useIntl() return (

{intl.formatMessage(messages.greeting, { name })}

{intl.formatMessage(messages.items, { count })}

{chunks} }} />

) } ``` src/Cart.tsx (loclizr) ```tsx import { Parts } from 'loclizr/react' import * as m from './loclizr/messages' export function Cart({ name, count, total }: { name: string; count: number; total: number }) { return (

{m.cart_greeting({ name })}

{m.cart_items({ count })}

{m.cart_total({ amount: total })}

{chunks} })} />

) } ``` Both render the same markup in `en` and `de`. The argument types come from the catalog, not from a type parameter, and a message is a plain function, so it needs no hook or component around it. `formatjs extract` finds nothing in a file once it moves, so a new string goes into `locales/en.json` and its description into `locales/en.meta.json` by hand. 7. ### Drive both libraries from one locale src/Providers.tsx ```tsx import type { ReactNode } from 'react' import { IntlProvider } from 'react-intl' import { useLocale } from 'loclizr/react' import de from '../locales/de.json' import en from '../locales/en.json' const catalogs = { de, en } export function Providers({ children }: { children: ReactNode }) { const locale = useLocale() return ( {children} ) } ``` Until the last call site moves, `IntlProvider` reads the compiled `locales/{locale}.json` that loclizr builds from, and `setLocale` switches both. The JSON imports need `resolveJsonModule`. Then delete the provider and key the root on `useLocale` ([React](https://yasinmiran.github.io/loclizr/guides/react/)). ## Hash ids A `FormattedMessage` with no `id` gets one from `formatjs extract`, a hash of its `defaultMessage` and `description`: ```tsx ``` src/loclizr/messages/\_root.d.ts ```ts // @generated by loclizr abi=1. Do not edit; run `loclizr build`. import type { EmptyArgs, MessageOptions } from 'loclizr' /** en: "Check out" */ export declare function rM52go(args?: EmptyArgs, opts?: MessageOptions): string ``` A key with no dot goes to `_root`, and characters outside an identifier, such as `+` and `/`, become `_` ([Generated code](https://yasinmiran.github.io/loclizr/reference/generated-code/#identifiers)). An `identifiers` entry renames the export: ```ts identifiers: { rM52go: 'checkout' }, ``` It holds only until the copy changes. Edit `defaultMessage` to `Go to checkout` and the next extract hashes it to `kLF5Q+`: src/loclizr/messages/\_root.d.ts ```ts // @generated by loclizr abi=1. Do not edit; run `loclizr build`. import type { EmptyArgs, MessageOptions } from 'loclizr' /** en: "Go to checkout" */ export declare function kLF5Q_(args?: EmptyArgs, opts?: MessageOptions): string ``` | What still names `rM52go` | Result | | ------------------------------------------------ | --------------------------------------------------------------------- | | `identifiers` | `LZ4007 identifier-orphan`, a warning naming the closest existing key | | `locales/de.json` | `LZ3003 extra-translation`, and `LZ3001` for the new key | | `locales/en.meta.json`, until the snippet reruns | `LZ1015 meta-orphan` | | `m.checkout()` call sites | A type error: the export is gone | Editing only the description renames it too. Give the message an explicit id before extracting and every name stays put; keep `identifiers` for the odd key you cannot rename yet. ## Rich text A tag in a message is the same in both libraries: `terms` takes a `link` handler that receives the chunks. src/loclizr/messages/terms.d.ts ```ts // @generated by loclizr abi=1. Do not edit; run `loclizr build`. import type { MessageOptions } from 'loclizr' /** en: "Read our terms before you continue." */ export declare function terms_accept( args: { link: (chunks: readonly (string | T)[]) => T }, opts?: MessageOptions, ): readonly (string | T)[] ``` The call returns an array, and `Parts` from `loclizr/react` renders it. react-intl’s `defaultRichTextElements` has no counterpart, so a `` that every message shares is passed at every call site. react-intl also takes a React element as a plain value, as in `values={{ name: Ada }}`. A loclizr argument is `string | number`, so the same call does not compile: ```text src/Bad.tsx(3,43): error TS2322: Type 'Element' is not assignable to type 'string | number'. ``` Move the element into the message as a tag, `"cart.welcome": "Welcome back, {name}"`, and pass the handler: ```tsx {chunks} })} /> ``` ## What the build accepts | File in `locales/` | Result | | ------------------------------- | ------------------------------------------------------------- | | `formatjs compile` output | One message per id. | | `formatjs compile --ast` output | `LZ1010 catalog-shape-invalid` per message; nothing compiles. | | The `formatjs extract` file | `LZ1010 catalog-shape-invalid` per message; nothing compiles. | An AST value is an array of nodes, and the hint names the fix: ```text error LZ1010 catalog-shape-invalid locales/en.json:2:20 en cart.checkout "cart.checkout" is an array, not a message string. fix run formatjs compile again without --ast. A catalog holds message strings, not the formatjs AST. ``` An extract record is an object holding a string `defaultMessage` and nothing but the fields `formatjs extract` writes beside it (`id`, `description`, `file`, `start`, `end`, `line`, `col`): ```text error LZ1010 catalog-shape-invalid locales/en.json:2:20 en cart.checkout "cart.checkout" is a formatjs extract record, not a message string. fix run formatjs compile on the extract file and point catalogs at its output; carry each description into the meta sidecar. ``` Steps 2 and 3 are the fix. A record in a translated extract file is the same error, and its key then falls back to source text with an `LZ3001 missing-translation`. # Importing i18next catalogs > Build an existing i18next JSON catalog in place, then move call sites over one at a time. loclizr.config.ts ```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](https://yasinmiran.github.io/loclizr/reference/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. | | ``, `` 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](https://yasinmiran.github.io/loclizr/guides/catalogs/). ## Migrate an app No codemod: call sites are swapped by hand, with both libraries installed until the last one moves. 1. ### Build the existing catalog Point `catalogs` at the files, leave `catalogFormat` at `'auto'`, and run `npx loclizr build --reporter json`. 2. ### Fix the errors 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}} ```txt 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. ### Pick `i18nextMarkup` `'tags'` if the app used `Trans`, otherwise the default `'literal'`. 4. ### 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. 5. ### 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 | | `` | `` 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. ### Gate CI Add `npx loclizr check` to CI. 7. ### Remove i18next Only once `summary.errors` is `0` and no `useTranslation` is left. The backend and its bundle go last. ## 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 | 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 ```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. ### Orphan suffixes A suffixed key with no `_other` sibling stays an ordinary key and warns. So does a JSON v3 pair: ```text 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. ``` ```text 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 `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: ```text 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 {...}}" ``` Caution Convert the file first. The select pasted into a file that still contains `{{` is ICU in a file read as i18next, and renders as literal text. ## 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: ```text 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 ```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: ```text 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: ```text 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 | `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 ```ts /** en: "Read our terms before you continue." */ export declare function terms( 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 `
`. 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"` and `"greeting": "Hi {{name}}"`, which makes the file read as i18next, warns per mode: i18nextMarkup: 'literal' ```text 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' ```text warn LZ1016 i18next-markup-literal locales/en.json:3:14 en accept The tag 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 ... (a self-closing
stays text): name a numbered tag and move attributes to the call site. ``` `m.accept()` renders `I accept the <1>terms of service` in both modes; `'literal'` stays silent, since `<1>` never parses as a tag. Name the tag, `terms of service`, and under `'tags'` it lowers to a handler as `` does. ## 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: loclizr.config.ts (written by init on an existing catalog) ```ts 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. # Language switching > The locale is read at call time, so a switch needs no reload. src/loclizr/messages.d.ts ```ts export type AppLocale = 'de' | 'de-AT' | 'en' export declare function getLocale(): AppLocale export declare function setLocale(locale: AppLocale, options?: SetLocaleOptions): void export declare function subscribe(listener: () => void): () => void export { cookie, locales, sourceLocale } from './messages/_locale.js' ``` The barrel re-exports the one store in `loclizr`, narrowed to your locales. Outside React, use `subscribe` directly; it returns its remover. src/Switcher.tsx ```tsx import { useLocale } from 'loclizr/react' import * as m from './loclizr/messages' const offered = [m.sourceLocale, ...m.locales.filter((tag) => tag !== m.sourceLocale)] export function Switcher() { const locale = useLocale() const names = new Intl.DisplayNames([locale], { type: 'language' }) return (
{offered.map((tag) => ( ))}
) } ``` `m.locales` is the frozen tuple `['de', 'de-AT', 'en']`; `m.sourceLocale` is `'en'`. ## How the locale is resolved First match wins: 1. An active server request scope. 2. What `setLocale` stored. 3. With a DOM, detected once: the cookie, then ``. 4. The source locale. Matching is exact, then by cutting subtags (`de-CH` to `de`). An unknown tag, such as a cookie edited to `sp`, renders the source locale, never `undefined`. ### First visit on a static site src/main.tsx ```tsx import * as m from './loclizr/messages' function pickFirstLocale(): void { const chosen = document.cookie.split(';').some((pair) => pair.trim().startsWith('locale=')) if (chosen) return for (const tag of navigator.languages) { const wanted = tag.toLowerCase() const language = wanted.split('-')[0] const match = m.locales.find((known) => known.toLowerCase() === wanted) ?? m.locales.find((known) => known.toLowerCase() === language) if (match !== undefined) { m.setLocale(match) return } } } pickFirstLocale() ``` With no server, a first visit renders the source locale. v0.1 leaves that gap to you; this helper closes it: * Call it before the first render. * It picks the first browser language in `m.locales`: exact tag (`de-AT`), then language subtag (`de-CH` to `de`). * It does nothing once a cookie is set. `locale=` is the default name; match your `cookie` setting. * `setLocale(match)` persists, so a later browser language change is not re-detected. `{ persist: false }` re-detects each visit until the visitor picks. Caution Not `negotiate`: `loclizr/server` imports `node:async_hooks` and does not bundle for the browser. ## Persistence `setLocale('de')` writes the cookie (`cookie` in `loclizr.config.ts`, default `locale`), sets `document.documentElement.lang`, then notifies subscribers. Where the page keeps cookies, the cookie is the only persistence, since a server reads it back. | Case | Effect | | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{ persist: false }` | this page load only | | `file://` page (Electron `loadFile`) or cookies blocked | this page load only; persist the choice yourself and call `setLocale` on startup. Outside production loclizr warns once: `loclizr: the locale cookie was not stored (file:// or blocked), so the choice ends on reload. Persist it and call setLocale() on startup.` | | No `document`, React Native included | memory only, subscribers still notified | Cookie attributes are fixed in v0.1: `path=/`, `max-age` one year, `SameSite=Lax`, no `Domain`, no `Secure`. No `Domain` means host-scoped: `shop.example.com` and `help.example.com` do not share a choice. `setLocale` never sets `dir`; for an RTL locale set it yourself from `useLocale()` or a `subscribe` listener. After a reload following a persisted switch, the store reads the cookie and sets `` to match, so a client-only app sees no `` warning; [React](https://yasinmiran.github.io/loclizr/guides/react/) covers the server-rendered case. Caution `setLocale` throws inside a server request scope: ```text loclizr: setLocale() cannot run inside a request scope. Pass { locale } per call instead. ``` [Server rendering](https://yasinmiran.github.io/loclizr/guides/server-rendering/) has the per-request shape. ## The per-call override ```ts m.cart_items({ count: 3 }, { locale: 'de' }) // "3 Artikel in deinem Warenkorb" ``` A second-argument `{ locale }` beats the store for that call. The switcher below works this way, so the page around it keeps its own language: LanguageEnglish (en) (en)NameAdaItems3Statusshipped Hello, Ada! 3 items in your cart Total: €73.50 On its way Updated Oct 3, 2026 `m.demo_items({ count: 3 }, { locale: 'en' })` ## What re-renders, and what does not ![setLocale writes the store, which sets the cookie and notifies useLocale subscribers. A root keyed on the locale remounts; a component passing the locale per call re-renders in place. Message functions read the store at call time either way.](https://yasinmiran.github.io/loclizr/_astro/language-switch.B_ynqahJ_Z1peJP9.svg) Generated code imports nothing from React, so a component that only calls messages updates when its parent next renders: | Pattern | On a switch | Local state | | -------------------------- | ----------------------------------------- | ------------------ | | Key the root on the locale | subtree remounts, every string recomputed | lost below the key | | Pass `{ locale }` per call | component re-renders in place | kept | Combine them: key the root for the bulk, pass per call where state must survive. ### Key the root on the locale src/main.tsx ```tsx function Shop() { const locale = useLocale() const [cart, setCart] = useState(openingCart) return ( <> setCart((current) => ({ ...current, ...patch }))} /> ) } ``` `cart` survives the remount because it lives above the key, in `Shop`; `App`’s own `useState` would not. ### Pass the locale per call src/CourierNote.tsx ```tsx export function CourierNote() { const locale = useLocale() const [note, setNote] = useState('') return (