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

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

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

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

```js title="src/loclizr/messages/_root.js (en arm)"
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

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

> **Tip**
>
> `formats.timeZone` applies to every date and time style. `LZ3011 date-without-timezone` ships off;
> [Server rendering](https://yasinmiran.github.io/loclizr/guides/server-rendering/) says when to turn it on.

## Markup

`<link>terms</link>` makes `link` a required argument; the message returns an array, not a string.

```ts title="src/loclizr/messages/terms.d.ts"
/** en: "Read our <link>terms</link> before you continue." */
export declare function terms_accept<T>(
  args: { link: (chunks: readonly (string | T)[]) => T },
  opts?: MessageOptions,
): readonly (string | T)[]
```

- Tags nest: `<b><i>full</i> terms</b>` 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/)).

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

> **Note**
>
> `LZ3012` ships at `warn`. `loclizr init` raises it to `error` on a fresh project and leaves the
> line commented out on an existing catalog, which would otherwise fail on every shared "Save".
