# Typed message functions and catalog checks from JSON

A compiler for the contract between code and translations.

## Sixty seconds

A JSON catalog in, ICU MessageFormat by default. i18next catalogs are detected per file and
converted in memory.

```json title="locales/en.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.

```ts title="src/Cart.tsx"
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


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):

```json title="locales/loclizr.context.json (one entry)"
{
  "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}`.

```json title="locales/de.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.

```txt frame="terminal" title="loclizr build"
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.

```js title="src/loclizr/messages/cart.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.

```txt frame="terminal" title="loclizr build"
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
```

```js title="src/loclizr/messages/cart.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/).
