Bridge Training
i18n Messages

Scripts

Generate missing translations with Claude and audit unused keys.

The package has two maintenance scripts. Run them from the repo root with pnpm --filter:

pnpm --filter @workspace/i18n-messages generate-translations
pnpm --filter @workspace/i18n-messages audit-keys

generate-translations

scripts/generate-translations.ts fills in missing keys in the player-facing namespaces by asking Claude to translate them from English.

The player runs with fallbackLng: false (see Consumption), so every key must exist in every language. Other namespaces (account, deals, deal-editor, errors, …) are still translated by hand.

Prerequisites

The script calls claude -p (Claude Code headless mode, --output-format json, model sonnet), so it uses your existing Claude Code login (claude auth status). It needs no ANTHROPIC_API_KEY, but the claude binary must be on your PATH.

Workflow

  1. Add or change keys in <namespace>/en.json.
  2. Run the generator. Without --force, it only fills keys that are missing.
  3. Commit en.json together with every generated locale file.
pnpm --filter @workspace/i18n-messages generate-translations --dry-run
pnpm --filter @workspace/i18n-messages generate-translations --namespace player --lang de,it

Options

FlagDefaultEffect
--namespace <ns[,ns…]>player,bridge,game,common,billingNamespaces to fill
--lang <lang[,lang…]>fr,de,es,ca,nl,pl,pt,el,sv,itTarget languages (en is dropped with a warning)
--forceoffRe-translate keys that already exist. This overwrites them, so use it only for one-off corrections
--dry-runoffTranslate and validate, but do not write files
-h, --helpPrint usage

What it does

  1. Reads <namespace>/en.json as the reference shape. A missing target file counts as empty.

  2. Collects every leaf (a string or an array of strings) that exists in English but not in the target language (or every leaf, with --force).

  3. Sends the leaves in batches of 50 keys, running up to 8 namespace × language pairs at a time. The system prompt is scripts/translation-agent/system-prompt.md, and the prompt also includes the bridge glossary scripts/translation-agent/bridge-glossary.md.

  4. Validates each batch:

    • the keys match the request exactly (none missing, none extra)
    • {{placeholders}} match the source
    • no empty strings
    • arrays keep their length
    • no value just repeats its key path

    If a batch fails validation, the script exits with a non-zero code.

  5. Merges the result into the target file in the same key order as en.json, with a 2-space indent and a trailing newline.

Adding a language to the player

  1. Add the code to SUPPORTED_LANGUAGES in apps/player/src/lib/i18n/constants.ts (and to src/supported-languages.ts here).
  2. Import and export the new locale in each namespace's index.ts.
  3. Create <namespace>/<lang>.json containing {}.
  4. Run the generator with --lang <lang> and commit.

audit-keys

scripts/audit-unused-keys.ts lists translation keys that no product source uses, and can delete them from every locale file.

pnpm --filter @workspace/i18n-messages audit-keys                                # report, all namespaces
pnpm --filter @workspace/i18n-messages audit-keys --namespace deals,teaching
pnpm --filter @workspace/i18n-messages audit-keys --csv report.csv --json report.json
pnpm --filter @workspace/i18n-messages audit-keys --prune                        # delete unused keys in all locales
pnpm --filter @workspace/i18n-messages audit-keys --fail-on-unused               # exit 1 if any remain (CI)

Options

FlagEffect
--namespace <ns[,ns…]>Audit only these namespaces (default: every folder in src/locales)
--json <file>Write { namespace, total, used[], unused[] } per namespace
--csv <file>Write namespace,key,status rows
--pruneDelete unused leaves from every <namespace>/*.json, remove parents left empty, and keep key order
--fail-on-unusedExit 1 when any unused key remains
--no-tsSkip the TypeScript property-reference detector
--ts-project <tsconfig>Project to scan for property references (can be repeated). Default: apps/web/tsconfig.json, packages/i18n-messages/tsconfig.package.json

The report prints total / used / unused per namespace, then lists the unused keys.

How usage is detected

The audit runs two detectors. A key counts as used if either one finds it.

  1. String literals. It parses every .ts/.tsx/.js/.jsx/.mjs file under apps/ and packages/ with the TypeScript parser and records every string literal: t("a.b"), "ns:a.b", key registries, i18nKey="a.b", and so on. For .mdx and .astro files it uses a regex scanner instead. A leaf is used when a literal matches its dotted path or its i18next plural base (x_one → x), either on its own or prefixed with namespace:.
  2. Typed property access. For consumers that read the JSON as an object (dictionary.home.hero.title in apps/web, rotationMessages[code].direction), the TypeScript checker traces member accesses, ["key"] element accesses and destructuring back to the property in the locale JSON.

An ancestor key marks its whole subtree as used only when it is read as an object: t("faq.items", { returnObjects: true }) or a property reference. A bare literal such as "deals" does not count.

Excluded from the scan

node_modules, build outputs, __tests__, __mocks__, e2e, *.test.*, *.spec.*, *.gen.ts and *.d.ts files are not scanned. Neither is apps/docs: a key used only in a docs story is dead in the product. Locale JSON files are not scanned either, but the key registries next to them (locales/bridge/keys.ts) are.

Allowlist

The audit cannot see some consumers: a build-time plugin that reads the JSON from disk, or code that walks an untyped object. Declare the subtrees they use in scripts/audit-keys.allowlist.ts, with one entry per namespace and a comment naming the consumer. Each entry marks the whole subtree as used.

export const AUDIT_KEYS_ALLOWLIST: Record<string, readonly string[]> = {
  player: [
    // apps/player/vite/unsupported-page.ts reads `<lang>.json` from disk at
    // build time and inlines `locale.unsupported` into unsupported.html.
    "unsupported",
  ],
};

Known blind spots

  • A key built by string concatenation outside t(). A Biome plugin guards t() itself, but not other code. Read the report before pruning.
  • A key read through an untyped object in a project not passed with --ts-project, or read from outside TypeScript entirely. Add it to the allowlist.

Always run the report before --prune, and commit the pruned JSON so it can be reviewed.