Skip to main content
Without a plugin, Narrator reads library calls from their names. That gets you a long way, but it can’t know that new Decimal(a).lte(b) is a comparison or that Flags.isEnabled("x") is a feature check. A plugin adds that knowledge. This guide builds up the decimal.js plugin from the repository, then covers rule keys, guards and testing.

What a plugin changes

Here is a small plugin for an in-house feature flag helper and a retry wrapper, before and after:
flags-plugin.ts
Output
Three things happened. A condition rule turned Flags.isEnabled("new-checkout") into a sentence about a feature. A call rule turned withRetry(...) into “retrying up to 3 times”. The vocabulary field changed how the abbreviation cfg reads. The rest of the file is still narrated by the built-in rules.

Build the decimal plugin

The decimal plugin is about 80 lines and uses every common feature, so it is a good model.
1

Describe your phrasebook

A plugin never hard-codes English inside its rules. It declares the shape of everything it says as a type, so that other locales can provide the same phrases.
phrases.ts
Arguments arrive as already-rendered text, with markup included, so phrases only arrange words.
2

Write the English phrases

English is required. It is the fallback for every other locale and the reference that translators compare against.
en.ts
3

Write call rules

A call rule turns a call into a noun phrase, such as “price times quantity”. It receives the call’s receiver (obj), its arguments (args), the engine (e) and your phrasebook (say).
e.noun(node) renders any expression and returns a Doc: the inline text t plus any block lines (kids) the expression needed, such as a multi-line callback. Passing the kids through keeps those lines attached. When you only need the inline text, e.text(node) does this for you.
4

Write condition rules

A condition rule is used when a call appears where a yes or no answer is expected, such as an if. It also receives neg, which is true when the condition is negated. Use it to say the opposite naturally instead of “it is not the case that”.
A negated lte reads as “is more than”, and a negated gt reads as “is at most”.
5

Put it together

Rules are keyed by the callee they handle. *.plus matches .plus(...) on any receiver.
index.ts
The real plugin has a few more aliases (sub, mul, div, eq). definePlugin is an identity function that infers the phrasebook type from phrases.en, so every rule’s say is fully typed.
6

Try it

English

Rule keys

The engine looks up call rules by the shape of the callee. A call can match several keys, and they are tried from most to least specific. A tagged template rule receives the template literal as args[0]. When several plugins register the same key, the plugin listed later wins, and the built-in std plugin always comes first. If a rule returns null or undefined, the next rule for that key is tried, then the next key, and finally the engine’s generic phrasing.

Key grammar

In the keys below, name is a JavaScript identifier (letters, digits, _ and $, not starting with a digit), and * is a literal wildcard.
  • Call and condition keys are one of name, new name, name.name, new name.name, name.*, new name.*, *.name, *.name.name, a curried call inner(), or a tagged template inner or inner() , where inner is name, name.name or *.name.
  • Member keys are one of name.name, name.*, *.name, *.name.name or *.name.*.
A bare call key may also be any other name without spaces, dots, parentheses or backticks, for plugins that rewrite the syntax tree and give a node a made-up callee. Any other key can never match, so the engine prints a console.warn naming the plugin and the key the first time a plugin with such a key is loaded. "Foo..bar", "z object" and "new *.x" are all reported.

Imports under another name

Keys are written with the names a library exports, but code often imports them under other names. When your plugin declares its library, a call rule also matches calls that reach your library’s export through an import: The import must come from library.name or one of its subpaths, such as date-fns/fp. Plugins without a library only match the names as written. This applies to call and condition rules, including new, curried and tagged forms of a call; member rules match the names as written.
import-aliases.ts
Output

Member rules and JSX

Calls aren’t the only thing a library gives meaning to. members rules read property access, such as c.env.DB in a Cloudflare Worker or env.STRIPE_KEY in a config module, and a jsx rule reads JSX elements wherever the engine meets one: in arguments, in lambdas and in returns. The React plugin uses a jsx rule to read JSX as UI. Member rules are keyed like call rules, from most to least specific: Obj.prop, *.owner.prop, *.prop, then the families Obj.* and *.owner.*. A rule receives the member expression as node, its object as obj and the property name as prop. When a rule claims an inner link of a longer chain, such as c.env.DB inside c.env.DB.prepare, that link becomes the owner of the rest of the chain.
members-and-jsx.ts
Output
Member rules apply where a property is read as a value. A property tested as a condition, as in if (!req.session.user), still reads as the property chain.

Guards

*.method keys are broad. *.add matches decimal.js, but it also matches set.add(x) and cart.add(item). A rule that claims calls it doesn’t understand makes narration worse, so check the shape first and return null when it isn’t yours. Common guards in the repository’s plugins:
  • Check that the file uses your library. e.usesLibrary("hono") is true when the file imports hono (or a subpath such as hono/cors), or when its package depends on Hono. Almost every library plugin checks this, so a .get() in an Express app or an unrelated Effect class is left alone. See Dependency detection.
  • Check where a name came from. e.imports maps each imported local name to its module and exported name, so e.imports.get("Link") tells you whether Link is next/link’s default export or someone else’s component.
  • Check the receiver. The decimal plugin only claims *.add when the receiver is a new Decimal(...) expression.
  • Check the arguments. Comparisons require exactly one argument. The flags example above only claims Flags.isEnabled when its argument is a string literal.
Plugins that need more facts about a file, such as which variables hold a Stripe client, compute them once per file from e.program, the file’s syntax tree, and cache them by engine or by e.program. e.path gives the file’s path, which the Next.js plugin uses to read routes.

When a rule fails

A rule that throws, or returns something other than a Doc, a string or null, doesn’t stop narration. The engine treats it as if the rule had returned null: the next rule for that key gets a try, then the engine’s own phrasing. Any lines the rule emitted before it failed are removed. The same goes for statements rules (which should return true or false), the jsx rule and describeSchema. Each failure becomes a problems entry on the narration, with source: "plugin", severity: "warning", the plugin’s name, the rule (for example calls["*.plus"] or statements[0]), the line it happened on and the error message. stats.ruleErrors holds the same failures with a count of how often each happened. A host can log them:
In tests, narrate and narrateWithStats from @usenarrator/testkit throw when any rule fails, so a buggy rule fails the test instead of quietly falling back. Returning null is still the right answer for anything a rule doesn’t recognise, because a failure costs a little time and leaves a warning behind.

Other plugin fields

Type your phrasebook for translators

Locales can override a plugin’s phrases under locale.plugins[name]. To make those overrides type-checked, add your phrasebook type to the PluginPhrases interface in @usenarrator/core:
The key must match your plugin’s name. See Translating Narrator. The full type is in the TsPlugin reference.

Test it

@usenarrator/testkit narrates a snippet with your plugin and returns plain text, which makes exact assertions easy. Every plugin in the repository is tested this way.
decimal.test.ts
Asserting fallbacks is zero catches the case where part of your snippet silently fell back to raw code. See the testkit reference for the full API.