> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ooneex.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Internationalization

> Per-feature translation dictionaries and a generated hook that resolves the active language from the URL.

In a [spa module](/spa/overview), internationalization is **per feature**. Each [feature](/spa/features) owns its own dictionary and a generated hook: a `translations.json` file holding every string keyed by locale, and a `use<Name>Translate` hook that reads the active language and looks keys up for you. The active language is resolved once, from the `?lang=` query param, by the shared `useLang` hook.

You scaffold this pair with `ooneex translation:create`. In a spa the generator produces a **hook**; backend modules get a `Translation` class instead — same dictionary format, different consumer. See the [translation component](/components/translation) for the shared engine both build on.

## How it works

Translations live next to the feature that uses them, under `src/features/<feature>/translations/`. The generator writes two files: the dictionary and the hook. The dictionary is written once and never overwritten — you grow it by hand; the hook wraps the `trans`/`has` primitives from `@ooneex/utils/trans` and binds them to the current language.

| Piece                         | What it does                                                                                                                                  |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `translations.json`           | The locale dictionary — nested keys whose leaves are `{ en, fr, … }` maps. Written once per feature, never regenerated.                       |
| `use<Name>Translate.ts`       | A generated hook returning `{ trans, has }`. Resolves the active language via `useLang` and looks keys up in the dictionary.                  |
| `useLang`                     | Shared hook (`src/shared/hooks/useLang.ts`) that reads `?lang=` from the URL, defaulting to `en`. Scaffolded with the first translation hook. |
| `trans(key, params?, count?)` | Returns the translated string, interpolating `{{ params }}` and pluralizing on `count`. Falls back to the key itself when no entry matches.   |
| `has(key)`                    | Returns whether the dictionary contains an entry for `key`.                                                                                   |

## The dictionary

`translations.json` is a tree of keys. Each leaf is an object mapping locale codes to strings. Keys nest freely, so a feature can group its strings however it likes. Strings may contain `{{ param }}` placeholders that are filled at call time, and a key may carry plural variants selected by a `count` argument.

```json translations.json theme={null}
{
  "title": { "en": "Settings", "fr": "Paramètres" },
  "greeting": { "en": "Hello {{ name }}", "fr": "Bonjour {{ name }}" },
  "items": {
    "_zero": { "en": "No items", "fr": "Aucun élément" },
    "en": "{{ count }} item",
    "_plural": { "en": "{{ count }} items", "fr": "{{ count }} éléments" }
  }
}
```

A few rules govern how a key resolves:

* **Locale leaves.** The active language (from `useLang`) selects which leaf is used. `title` with `lang = "fr"` yields `"Paramètres"`.
* **Interpolation.** `{{ param }}` placeholders are replaced by the matching entry in `params`. `greeting` with `{ name: "Ada" }` yields `"Hello Ada"`.
* **Pluralization.** When you pass `count`, the resolver picks among sibling keys: `_zero` when `count` is `0`, `_plural` when `count` is plural, and the base entry otherwise. `{{ count }}` itself is available as an interpolation param. So `items` with `count = 0` yields `"No items"`, `count = 1` yields `"1 item"`, and `count = 5` yields `"5 items"`.
* **Fallback.** When no entry matches a key, the hook returns the key string unchanged — missing translations surface as the key rather than crashing.

## Resolving the language

The active language comes from a single shared hook. It reads the `?lang=` search param off the current URL and defaults to `en`, so `?lang=fr` switches the whole app to French without any extra state.

```typescript src/shared/hooks/useLang.ts theme={null}
import { useSearch } from "@tanstack/react-router";

export const DEFAULT_LANG = "en";

// strict: false opts out of route-specific search typing so it works from any route.
export const useLang = (): string => {
  const search = useSearch({ strict: false }) as { lang?: string };
  return search.lang ?? DEFAULT_LANG;
};
```

Because every translation hook routes through `useLang`, changing the query param re-renders the consuming components in the new language — there is no separate language store to keep in sync. See [routing](/spa/routing) for how search params flow through the router.

## The generated hook

`ooneex translation:create` writes a `use<Name>Translate` hook per feature. It imports the feature's `translations.json`, resolves the language with `useLang`, and exposes `trans` and `has` bound to that language and dictionary.

```typescript src/features/settings/translations/useSettingsTranslate.ts theme={null}
import { has, trans, type TransDictType, type TransParamsType } from "@ooneex/utils/trans";
import { useLang } from "../../../shared/hooks/useLang";
import dict from "./translations.json";

export const useSettingsTranslate = () => {
  const lang = useLang();
  return {
    // Translates key, interpolating params and pluralizing on count.
    // Returns the key itself when no matching entry exists.
    trans: (key: string, params?: TransParamsType, count?: number): string => {
      const result = trans(dict as TransDictType, key, { lang, params, count });
      return result.found ? result.value : key;
    },
    has: (key: string): boolean => has(dict as TransDictType, key),
  };
};
```

## Using it in a component

Call the hook at the top of a component, then translate by key. Pass `params` for interpolation and `count` to drive plural selection; use `has` to branch on whether a key exists.

```tsx src/features/settings/components/SettingsHeader.tsx theme={null}
import { useSettingsTranslate } from "../translations/useSettingsTranslate";

export const SettingsHeader = ({ name, count }: { name: string; count: number }) => {
  const { trans, has } = useSettingsTranslate();

  return (
    <header>
      <h1>{trans("title")}</h1>
      <p>{trans("greeting", { name })}</p>
      <span>{trans("items", { count }, count)}</span>
      {has("subtitle") && <small>{trans("subtitle")}</small>}
    </header>
  );
};
```

Switch the rendered language by changing the URL — `?lang=fr` flips every string this hook resolves to French, falling back to `en` when no `lang` is present.

## Best practices

* **One dictionary per feature.** Keep each feature's strings in its own `translations/translations.json` next to the code that uses them — don't reach into another feature's dictionary.
* **Grow dictionaries, don't overwrite them.** The generator writes the dictionary once. Add new keys and locales by hand; never regenerate over a populated dictionary, since it is never overwritten by design.
* **Lean on the key fallback.** Missing keys render as the key itself, so a forgotten translation is visible in the UI rather than a runtime error — search for raw keys when auditing coverage.
* **Drive language from the query param.** Resolve language only through `useLang`/`?lang=`; don't thread a language prop through components or duplicate the logic.
* **Use `count` for plurals.** Always pass `count` for countable strings so `_zero`/`_plural` selection works, and reference `{{ count }}` in those entries.

## Related

* [Spa overview](/spa/overview) — how spa modules are organized.
* [Spa structure](/spa/structure) and [features](/spa/features) — where `translations/` lives.
* [Routing](/spa/routing) — how search params like `?lang=` reach the hook.
* [Translation component](/components/translation) — the shared `trans`/`has` engine.
* [`translation:create`](/cli/commands/translation-create) — the generator command reference.
* [Internationalization (advanced)](/advanced/internationalization) — the model across module types.
