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
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.Arguments arrive as already-rendered text, with markup included, so phrases only arrange words.
phrases.ts
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 A negated
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”.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. The real plugin has a few more aliases (
*.plus matches .plus(...) on any receiver.index.ts
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 callinner(), or a tagged templateinnerorinner(), whereinnerisname,name.nameor*.name. - Member keys are one of
name.name,name.*,*.name,*.name.nameor*.name.*.
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 itslibrary, 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
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")istruewhen the file importshono(or a subpath such ashono/cors), or when its package depends on Hono. Almost every library plugin checks this, so a.get()in an Express app or an unrelatedEffectclass is left alone. See Dependency detection. - Check where a name came from.
e.importsmaps each imported local name to its module and exported name, soe.imports.get("Link")tells you whetherLinkisnext/link’s default export or someone else’s component. - Check the receiver. The decimal plugin only claims
*.addwhen the receiver is anew Decimal(...)expression. - Check the arguments. Comparisons require exactly one argument. The flags example above only claims
Flags.isEnabledwhen its argument is a string literal.
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 aDoc, 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:
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 underlocale.plugins[name]. To make those overrides type-checked, add your phrasebook type to the PluginPhrases interface in @usenarrator/core:
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
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.