Translate a plugin
Your plugin.yaml is written in one language (its locale: field, fr by
default) — labels, descriptions, value-set concept displays. This recipe adds a
translation for another locale without touching the manifest, using the
meridian-plugin i18n CLI, then covers the separate runtime strings your
behaviors emit through ctx.t. It assumes a scaffolded plugin (see
Scaffold a plugin); for the design behind derived keys
and edge-resolved fallback, see
Instance vs UI locale.
1. Extract a bundle skeleton
From the plugin's directory, run extract for the locale you want to add:
meridian-plugin i18n extract --locale en
(libs/plugin-cli/src/commands/i18n.ts, i18nExtractCommand). This writes or
refreshes i18n/en.yaml next to plugin.yaml, creating the i18n/ directory
if it doesn't exist yet. The command reports what it did:
@acme/vitals → i18n/en.yaml (14 clé(s) au total, 14 ajoutée(s)).
Run it again any time you add, rename, or remove a manifest entry — it is non-destructive: every key already translated in the bundle is kept untouched, only new keys are appended and stale ones (renamed or removed manifest entries) are reported as orphans rather than deleted:
⚠ 1 clé(s) orpheline(s) (hors manifeste, conservée(s) telles quelles) :
- nodes.old-node-id.label
2. Fill in the translations
Open i18n/en.yaml. Every key extract couldn't find an existing value for
is left empty, with the source-language text as a # source: comment right
above it so you know what to translate:
nodes:
patient.load-conditions:
label: ""
# source: Load conditions
inputs.count.description: ""
# source: Maximum number of conditions to return.
Replace each empty "" with the translation and remove the now-redundant
comment (leaving it is harmless — check, in the next step, ignores
comments). The bundle format is two levels — section → id → {path: translation} — because ids themselves contain dots (e.g.
patient.load-conditions):
| Section | Key derived from |
|---|---|
plugin._.description |
the manifest's top-level description |
nodes.<id>.label / .description |
a node's label/description |
nodes.<id>.inputs.<port>.description / outputs.<port>.description / config.<field>.description |
a node's port and config field docs (a derivedPorts rule with a literal name and description included) |
nodes.<id>.config.<field>.schema<pointer>.title / .description |
the title and description of a config field's schema, at its JSON pointer (config.fields.schema/items/properties/name.title) |
nodes.<id>.assist.sampleHint / assist.previewHint |
the hints of a node's configuration workshop |
nodes.<id>.receive.transports.<name>.hint |
the hint of a protocol's own transport, shown in the connection form (GET /api/connections/protocols?locale=) |
categories.<name>.label |
a node's category (one entry per distinct label) |
types.<name>.doc / fields.<field>.doc |
a contributed type's docs |
valueSets.<id>.doc / concepts.<code> |
a value-set's docs and concept displays |
dimensions.<name>.label |
a contributed dimension's label |
contextKinds.<name>.description |
a context kind's description (its name, a context key, is never translated) |
conversions.<from>-><to>.label |
a conversion's label |
events.<type>.description / fields.<field>.description |
a declared event's description and its fields' descriptions (one entry per event, also when two events share their fields through a YAML anchor) |
agents.<slug>.description / inputs.<port>.description / outputs.<port>.description |
an agent's docs (its name, which drives the node id, is never translated) |
eventSources.<name>.description / eventBindings.<name>.description |
an event source's or binding's description |
The output descriptions of a trigger stay under nodes. A declarative trigger's
output that repeats an event field (outputs omitted, or an output declared without
description) falls back to events.<type>.fields.<field>.description of the plugin that
declares the event.
An event source's label and params have no key, like the label and parameters of a
connection type: the console's connection form shows them as written in the manifest.
This table mirrors the manifest walker that both extract and the running
host use (libs/shared/src/plugin/i18n.ts, manifestI18nEntries) — you never
invent a key here; if a string doesn't show up after extract, it isn't a
translatable field. For the fields that back each section, see
Plugin manifest.
3. Add runtime strings your behaviors emit
Strings a behavior builds at execution time (validation titles, notification
bodies) don't come from the manifest, so extract can't derive them — they go
under the bundle's free-form runtime section, keyed however you like:
runtime:
_:
drug-safety-alert.title: Medication safety alert
drug-safety-alert.issue-line: "• [{severity}] {detail}"
Add the same keys, translated, to every locale bundle you ship. extract
preserves this section verbatim across re-runs (it's the runtimeRaw passed
through untouched in libs/plugin-cli/src/commands/i18n.ts), so it's safe to
run extract again after editing runtime by hand. For how a behavior reads
these keys through ctx.t(key, params) — instance locale → your plugin's
source locale → the raw key — see
Implement a node behavior §5.
A validation title changed in a new version of the plugin (a new wording, a new
translation of its runtime key) applies to the tasks that start after it: a run of
journal format 2
journals the title, payload and deadline of a task when the task starts. A run of
format 1 computes the title again at each resumption: the new title
blocks the runs of format 1 suspended on that task on a journal mismatch until the
previous version is loaded again (see
Recover runs blocked by a missing plugin).
4. Check coverage
meridian-plugin i18n check
(i18nCheckCommand) walks every i18n/*.yaml file and, for each, lists keys
the manifest expects but the bundle doesn't have (✗ … manquante(s)) and keys
the bundle has but the manifest doesn't recognize (⚠ … orpheline(s),
typically a stale key after a rename). It exits 1 if any locale is missing a
key — wire it into CI:
✓ en: à jour (14 clé(s)).
fr: aucun bundle i18n/ (rien à vérifier).
Two things to know before you rely on this gate:
- The source locale is exempt. A bundle whose filename matches the
manifest's
locale:(fr.yamlby default) is not required to repeat the manifest-derived keys — they already live inplugin.yaml. Its bundle, if present, exists only to carryruntimetranslations for that locale. checknever looks atruntimecoverage. Those keys are free-form and not derivable from the manifest, so there is nothing to diff them against — a missingruntimekey degrades silently to the untranslated key text at run time (see step 3 above and Set the instance language §3). Trackruntimecoverage by review, not by this command.
5. Ship the bundles
i18n/*.yaml must reach dist/ for an externally loaded plugin — npm run build (meridian-plugin build) copies every file under i18n/ into
dist/i18n/ automatically, next to the compiled manifest. See
Build and bundle for the full build output.
6. Verify
Confirm the host actually resolves your new locale, without needing a running
instance in that language. Start the API (or your dev server) and request the
plugin referential with ?locale=:
curl -s "http://localhost:4000/api/plugins?locale=en" | jq '.[] | select(.name=="@acme/vitals")'
Labels and descriptions should come back in English; any key you haven't
translated yet falls back to the source-locale text, then to the raw key —
never to an error. The same ?locale= parameter works on /api/catalog, which
serves what the visual editor consumes: node and type metadata, declared events
(eventTypes, with their descriptions and field descriptions) and context kinds
(contextKinds, with their descriptions):
curl -s "http://localhost:4000/api/catalog?locale=en" | jq '.eventTypes[] | select(.source=="@acme/vitals")'
The hints of a protocol's transports come back in the requested language from the protocol list of the connection form:
curl -s "http://localhost:4000/api/connections/protocols?locale=en" | jq '.items[] | {protocol, transports}'
Related
- Scaffold a plugin — where
plugin.yamland thei18n/layout start. - Plugin manifest — the fields each i18n key is derived from, and the source
locale:. - Implement a node behavior — writing
ctx.t(key, params)in a behavior. - Build and bundle — how
i18n/*.yamlreachesdist/i18n/. - Instance vs UI locale — why manifest i18n, runtime i18n, and the console's own UI locale are three independent layers.
- Set the instance language — the operator side: choosing which locale
ctx.tresolves into, and auditingruntimecoverage for it. - CLI reference — every
meridian-plugincommand and flag.