ADR 0057: Localized text is a reference resolved by whoever renders it
September 12, 2026 · View on GitHub
Status: Accepted
Context
An integration or plugin that wanted to show text in more than one language had nothing to build on: no shared key format, no fallback behaviour, no typed way to pass a string with arguments through to whatever renders it, and no way for the host — the one place that knows the active language — to supply that text on a plugin's behalf. Every text-bearing UI property, action display name and validation message was a plain string fixed at the moment the producer built it (#326).
Three constraints shaped the design. The host is the single authority for the active language. Resolution cannot happen where text is produced, because the host never sees a UI node at all — snapshots and patches are opaque bytes forwarded verbatim (ADR 0050), so there is no tree on the host side to walk. And a plugin author edits resources, not code, for every language but their own, so the file format has to support a reviewable translation workflow.
Migrating Macro Deck's own strings onto that framework
(#680) then hit three things it had not
settled: SDK metadata could not carry a reference at all, there was nowhere to put a thousand application
strings that was not the published SDK catalog, and roughly forty strings selected wording by a number
through count === 1 ? … : … — the fragment assembly the requirement rules out.
Decision
.resx is the canonical resource format
A catalog is Localization/Strings.resx plus one Strings.<culture>.resx per translation. A source
generator reads them through AdditionalFiles and emits a typed API plus the runtime catalog.
.resx was chosen for its tooling, not its merits as a format. JetBrains Rider's Localization
Manager is the one editor that gives a culture grid with every language side by side,
missing-translation highlighting ahead of a build, CSV export and import so a translator never touches
XML, and coordinated rename across every culture at once. No other format on the table has an equivalent
today.
A localized reference is data, resolved by the reader
LocalizedString carries a key and named arguments; LocalizedText is either that or already-final
literal text, convertible from both implicitly so a literal and a generated call compile against the same
property. Neither resolves anything itself — a resolver does, walking the requested culture, its neutral
culture, the catalog's own default and Macro Deck's default, falling back to a conspicuous [[scope:Key]]
rather than blank text.
Carrying the reference is what makes the host's raw-JSON relay compatible with localization at all:
LocalizedText serializes as a bare string when literal and as {"$localized": …} when it carries a
reference, so the byte-identical pass-through needs to know nothing about localization to carry either
shape. It is also what lets one shared session be read correctly by two readers on two cultures, even
though only one culture is configured host-wide today.
A plugin owns exactly one scope, read from its manifest. Macro Deck's own reusable catalog is macrodeck,
generated by the identical code path from the SDK's own resources, so the plugin-facing generator and
Macro Deck's own catalog cannot silently disagree about what a valid resource set is. Registering a
scope's catalog is atomic, so a concurrent reader sees either the whole old catalog or the whole new one.
The application's own strings live in macrodeck.app, a third scope shape for catalogs Macro Deck
ships but does not publish. The published catalog's keys are frozen and additive-only; putting the app's
strings there would make every incidental settings sentence a permanent plugin-facing API member. Keeping
them separate keeps the published catalog small enough to read in one sitting, which is the only way
"reuse it instead of duplicating" stays true advice.
The plugin declares a capability; the host asks for one culture at a time
A plugin declares the localization capability and answers two operations: describe (its scope and
shipped cultures) and catalog (one culture's key-to-template map, on request). The fallback chain only
ever needs the active culture and at most its neutral parent, so a bulk push at connect time would ship
cultures the host will never use. Bounded limits apply to both directions.
Metadata carries references too
IActionDefinition.Name/.Description, the action-parameter labels and factories, option and state
labels, and the config-flow titles and instructions became LocalizedText. Because it converts
implicitly from string, assigning a literal is unchanged; implementing one of the interface members
is not, which is exactly what the compatibility policy forbids. It was taken because 3.0 had not been
released and no third-party plugin existed to break. The additive alternative — a nullable
…Localized beside every one of those, forever — is a surface every future plugin author would have to
learn and every host path check, bought with a guarantee that currently protects nobody.
Two exclusions are part of the decision. ProvidedVariable.Name keeps its string type and gains an
additive display name: Name is identity, and a value meaning different things in different languages
cannot be an identity. Config entry titles keep their string type because they are product names
and account identifiers persisted to SQLite, and persisting a reference would freeze a key into stored
state and break the entry the day that key is renamed.
The internal host-to-client DTOs carry the reference rather than resolved text. Resolving host-side would have meant caching resolved text in the capability snapshot with no way to invalidate an action catalogue a client already holds when the language changes — reintroducing exactly the producer-side resolution this rejects.
Plurals are declared forms, selected at resolve time
An entry marked [plural] whose key ends in One or Other is one form of a family. The forms generate
a single member at the base key taking the count, and the reference carries that base key — so the
reader picks the form and a language change re-picks it. Other is required.
Two things are deliberate. Opt-in via the declaration, not inferred from the key ending: inferring
would silently change the generated API of any resource set that happens to name keys that way, and turn
a lone One into a build error. And One and Other only, selected by count == 1, for every
culture — not CLDR. The same choice has to be made identically by the C# resolver, the browser clients
and the Rust bootstrapper, and .NET ships no plural-rule data, so CLDR here would mean three
hand-maintained copies of a large rule set kept in step by hope. One rule that is visibly the same in all
three fails less quietly. It is exact for English, German, Italian, Spanish and French; a language needing
few or many requires extending the closed set first, which is why the form names are checked at build
time rather than being free text.
The clients ship the English text
The generator emits the default-language value of every key alongside the keys, and the clients seed their catalog with it. That gives the English fallback and a correct first paint, where previously every migrated string would have rendered its bracketed key for the width of a round trip.
Consequences
- A plugin gets a typed, compile-time-checked API instead of a stringly-typed lookup, and analyzer diagnostics catch a broken resource set at build time rather than at render time.
.resx's XML verbosity and its lack of a native nested-key shape are accepted costs of choosing it for its tooling. A plugin author who does not use Rider gets no editing convenience from this beyond what any text editor gives a well-formed XML file.- Every non-obsolete
macrodeckkey is a compatibility commitment: additive-only, and retiring one goes through the same lifecycle an SDK API does rather than disappearing outright. - A resolver walking a localized argument that is itself a reference is bounded to a fixed recursion depth, so authored data cannot produce an unterminated resolution loop.
- The clients carry roughly 55 kB more bundled text, and a key the host does not serve now renders
plausible English instead of a conspicuous marker — so "grep the running app for
[[" stops being a way to find missing keys. A test resolving every generated key against the catalog the host actually serves replaces it. - A plugin implementing
IActionDefinitionneeds a one-line type change to compile against 3.0. There is no deprecation window and nothing mechanical catches it, because the repository has no public-API baseline. - The wire shape was designed so descriptor DTOs could follow without breaking anyone. They did: the
plugin protocol's descriptors are typed as
LocalizedTextfrom protocol v3, and text travels as a reference at or above that version and is flattened to the plugin's own language below it. A plugin built against the string shape keeps working against this host, and one built against v3 keeps working against an older host. Version 3 is now the floor for any future descriptor field carrying user-facing text — adding one as a plain string would reintroduce the problem. - Two fields keep a plain string on the wire deliberately: the config entry title, for the reason above, and the handshake's capability display name plus the error envelope's message, because the first is declared before any version is negotiated and the second is shared with transport-level failures.
- Making a UI text property accept a reference alongside a literal is a type change on an existing wire value, so the UI model's major moved while the plugin protocol's did not — everything localization adds to the protocol is additive, and a localized value reads a plain string as literal text. The UI model version is negotiated between host and provider, so a plugin built against the older major emits only plain strings and keeps working.
- No third-party i18n library on the frontend. Transloco was evaluated and removed: its interpolation is its own, and this framework's substitution rules are pinned character-for-character against the C# formatter, so reconciling them would have meant bending the contract or bypassing Transloco's interpolation entirely — at which point it carries no weight. What remains is a signal over the catalog the host serves, which is what the requirement was actually protecting.
Alternatives considered
- JSON resource files. Arguably a better fit for the client half, but no JSON workflow gives a
translator the culture grid, missing-translation highlighting or coordinated rename Rider gives
.resx. - Android-style qualified XML. Rejected for the same reason: it would trade one hand-rolled tool-support story for another instead of adopting an existing one.
- Host-side resolution. Not merely worse but impossible under the current session design: it would require deserializing, mutating and reserializing every tree and patch, which is precisely the round-trip cost ADR 0050 was written to avoid.
- Resolution in the producer. The status quo this replaces — a language change would again require every producer to rebuild its UI and repush it.
- An additive dual-property shape to avoid a major bump. Would permanently double every text property on every node and DTO for the life of the format, to avoid a one-time bump both contracts were designed to absorb.