What a locale contains
ALocale has three parts:
grammarhandles 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.phrasesholds every phrase the TypeScript engine can produce, grouped intoexpressions,calls,conditions,statements,declarationsandtypes. 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.
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
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:
phrasesand each entry inpluginsare merged into the base key by key, however deeply nested.grammarreplaces helpers one at a time, so you can bring your ownlistandarticleand keep the rest.- The base can be any locale, including one made with
extendLocale, so a regional variant such ases-MXcan start fromes.
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:locale.plugins[plugin.name], an override supplied by the locale, wins where it has a phrase.plugin.phrases[locale.id], a translation the plugin ships itself, comes next.plugin.phrases.en, the English phrasebook every plugin must have, fills in everything else.
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:
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.isPluralSubjectto 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 asonlyAdults: it always reaches theconditions.flagphrase, which should treat its subject as singular.
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.