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

# Translating Narrator

> Write an output locale, translate plugin phrasebooks, and find what's missing.

Narrator keeps every word it says in phrasebooks, separate from the rules that decide what to say. That means a new output language is a new set of phrasebooks, and the rules don't change. English is the only complete locale today. This guide shows how a translation fits together using a small Spanish example.

## What a locale contains

A `Locale` has three parts:

* **`grammar`** handles word mechanics that change between languages: joining lists, choosing articles, plurals and singulars, possessives, negating a yes or no word, and deciding whether a phrase names a moment in time.
* **`phrases`** holds every phrase the TypeScript engine can produce, grouped into `expressions`, `calls`, `conditions`, `statements`, `declarations` and `types`. Each phrase is a function that takes already-rendered pieces and arranges them.
* **`plugins`** (optional) holds translations for plugins that don't ship this locale themselves, keyed by plugin name.

See the [Locale reference](/reference/locale) for the full type.

## A partial translation

You don't have to translate everything at once. `extendLocale` from `@usenarrator/core` builds a locale on top of English: you pass only the phrases you have, and everything you leave out stays English, key by key. That keeps the output readable while a translation is in progress.

```ts spanish.ts theme={null}
import { extendLocale } from "@usenarrator/core";
import { en } from "@usenarrator/locale-en";
import { decimal, decimalEn } from "@usenarrator/plugin-decimal";
import { narrate, phrasebookGaps } from "@usenarrator/testkit";

// A partial Spanish locale: every phrase left out falls back to English, key by key.
const es = extendLocale(en, {
	id: "es",
	phrases: {
		declarations: { let: ({ name, value }) => `Sea ${name} igual a ${value}` },
		statements: { ifThen: ({ condition, action }) => `Si ${condition}, ${action}` },
	},
	plugins: {
		decimal: {
			times: ({ a, b }) => `${a} por ${b}`,
			plus: ({ a, b }) => `${a} más ${b}`,
		},
	},
});

const code = `
const total = new Decimal(price).times(quantity).plus(shipping);
if (total.isZero()) stop();
`;

console.log(narrate(code, { plugins: [decimal()], locale: es }));
console.log(phrasebookGaps(decimalEn, es.plugins?.decimal ?? {}));
```

```text Output theme={null}
Sea total igual a price por quantity más shipping.
Si total is zero, stop.
{
  missing: [
    "minus", "dividedBy", "roundedTo", "compare.atMost", "compare.lessThan", "compare.atLeast",
    "compare.moreThan", "compare.equals", "compare.notEquals", "isZero", "isNegative", "isPositive"
  ],
  extra: [],
}
```

The first line is fully Spanish because both `declarations.let` and the decimal phrases were translated. The second line mixes languages because the decimal plugin's `isZero` phrase and the phrase for a plain action call like `stop()` are still English. The `phrasebookGaps` call at the end lists exactly which decimal phrases are still missing.

`extendLocale(base, { id, phrases, grammar, plugins })` works the same way at every level:

* `phrases` and each entry in `plugins` are merged into the base key by key, however deeply nested.
* `grammar` replaces helpers one at a time, so you can bring your own `list` and `article` and keep the rest.
* The base can be any locale, including one made with `extendLocale`, so a regional variant such as `es-MX` can start from `es`.

<Note>
  Identifiers are still read with English word rules, because source code is almost always written in English. A locale changes how those words are displayed through a plugin phrasebook's `glossary`, not how names are split.
</Note>

## How plugin phrases are chosen

When the engine loads a plugin, it builds the plugin's phrasebook for the active locale by layering three sources, phrase by phrase:

1. `locale.plugins[plugin.name]`, an override supplied by the locale, wins where it has a phrase.
2. `plugin.phrases[locale.id]`, a translation the plugin ships itself, comes next.
3. `plugin.phrases.en`, the English phrasebook every plugin must have, fills in everything else.

Any of the first two can be partial. A locale that only translates `times` and `plus` for decimal.js still gets English for every other decimal phrase, and a plugin's own `es` phrasebook is used where it has words even if a locale overrides a few of them. This lets a locale package translate third-party plugins without waiting for each plugin to add the language, and lets a plugin ship its own translations once they exist.

Every plugin exports its English phrasebook as `en`, typed with its own phrasebook type, such as `DecimalPhrases` or `ReactPhrases`. It's the reference for what a translation can contain.

### Typed overrides

`locale.plugins` is type-checked for plugins that register their phrasebook type with the `PluginPhrases` interface from `@usenarrator/core`. A plugin does that with declaration merging:

```ts theme={null}
declare module "@usenarrator/core" {
  interface PluginPhrases {
    decimal: DecimalPhrases;
  }
}
```

Once the plugin's package is imported, `plugins: { decimal: ... }` in a `Locale` is checked against `DecimalPhrases`. Every phrase is optional, because missing phrases fall back to English, but a misspelled phrase name or a wrong argument name is a compile error. Use `phrasebookGaps` (below) to find what's still missing. Plugins that don't register a type are still accepted under their name, without checking. Every plugin in the repository registers its type.

A plugin phrasebook can also carry `glossary`, `constants` and `errorLabels`. The engine merges these over the plugin's own fields, so a translation can say "cliente" for `cus` or give an error class a Spanish name.

## Grammar, gender and number

`grammar` covers the mechanics the engine and the English phrases lean on: joining lists, articles, plurals, possessives, negation and time words. Phrases receive pieces that are already rendered to text, so a phrase decides its own word order and agreement.

Two limits are worth knowing before you start:

* **Narrator doesn't track grammatical gender.** Nothing in the engine knows whether a noun is masculine or feminine. `grammar.article(noun)` receives the noun it precedes, so a locale can choose "un" or "una" from its own word list, and a phrase can inspect the pieces it receives. There is no gender argument on phrases.
* **Number is decided from the words.** English phrases call `grammar.isPluralSubject` to pick "is" or "are". A locale can implement that check for its own language. The one place the engine knows better is a yes or no setting such as `onlyAdults`: it always reaches the `conditions.flag` phrase, which should treat its subject as singular.

Some English also lives inside phrases you might not expect, such as loop labels ("each seat") and time comparisons ("is before"). If a sentence stays partly English after you translate the obvious phrase, run `phrasebookGaps` and look for the phrase that builds the leftover words.

## Write a full locale

<Steps>
  <Step title="Start from English with extendLocale">
    Create a package that exports `extendLocale(en, { id: "xx", ... })` and add phrases area by area. The full English phrasebook is the reference: it lives in [`packages/locale-en/src/phrases`](https://github.com/SirTenzin/narrator/tree/main/packages/locale-en/src/phrases) on GitHub, one file per area (`calls.ts`, `conditions.ts`, `declarations.ts`, `expressions.ts`, `statements.ts`, `types.ts`). The `CorePhrases` type in `@usenarrator/core` tells you each phrase's arguments.
  </Step>

  <Step title="Rewrite the grammar">
    Start with the `grammar` helpers. List joining, articles, plurals and possessives are used everywhere, so getting them right first makes every phrase easier. The English versions are in [`grammar.ts`](https://github.com/SirTenzin/narrator/blob/main/packages/locale-en/src/grammar.ts). Keep the helpers markup aware: a possessive must go after a name's closing marker, not inside it. See [Markup](/concepts/markup).
  </Step>

  <Step title="Translate area by area">
    Phrases receive pieces in a fixed shape, but they can arrange them in any order. Word order differences between languages belong in the phrase, not in the rules.
  </Step>

  <Step title="Translate the plugins you care about">
    Either add a phrasebook to each plugin (`phrases: { en, es }`) or put the translations in your locale under `plugins`. Both can be partial.
  </Step>

  <Step title="Check for gaps">
    Use `phrasebookGaps(reference, translation)` from `@usenarrator/testkit` in a test. It compares the key paths of two phrasebooks and returns `missing` and `extra` lists. Compare against your overrides, not the merged locale, which is always complete.

    ```ts theme={null}
    import { expect, test } from "bun:test";
    import { phrases as english } from "@usenarrator/locale-en";
    import { phrasebookGaps } from "@usenarrator/testkit";
    import { spanishPhrases } from "../src/phrases";

    test("the Spanish phrasebook is complete", () => {
      expect(phrasebookGaps(english, spanishPhrases)).toEqual({ missing: [], extra: [] });
    });
    ```
  </Step>
</Steps>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.