Skip to main content
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 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.
spanish.ts
Output
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.
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.

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

1

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

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. Keep the helpers markup aware: a possessive must go after a name’s closing marker, not inside it. See Markup.
3

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

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

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.