Translations
A plugin brings the translations of its own interface with it. Not the backend translations — those live centrally.
As lazy loaders, not as imports
localeLoaders: {
de: () => import('./locales/de'),
en: () => import('./locales/en'),
}
The files have the same structure as a language file of the core: one namespace, the keys below it.
// locales/de.ts
export default {
acmeShipping: {
title: 'Sendungen',
empty: 'Noch keine Sendung erfasst.',
count: 'Eine Sendung | {n} Sendungen',
},
}
The lazy loader is not a matter of style. A manifest is read completely at boot. A direct import of the language file therefore pulls every language of every plugin into the entry bundle of the application.
Measured before the change: 651 kB compressed on first paint, most of it language catalogues. Every user loaded both languages although using one. With lazy loaders boot brings only the active language, further ones follow when switching.
The older key locales with ready-made objects keeps working so that existing plugins do not break. For a new plugin it is the wrong choice.
Merging
Additive and deep. On a key conflict the core wins, and there is a warning. Two plugins cannot share a namespace without disturbing each other: choose your namespace as unambiguously as your plugin key.
{ and @ are syntax, not text
A message text is not a string but a small language:
{name}interpolates a value|separates singular and plural@:keyrefers to another key
A help text that wants to show an example therefore becomes invalid syntax. Whoever writes {{ feld }}, {SEQ:n} or raw JSON into a translation builds in an error.
The treacherous part: in development it is only a console message. In the shipped build the translation throws, and the view stops rendering. So the error only surfaces after shipping.
The literal notation is the correct one:
export default {
acmeShipping: {
hinweis: "Platzhalter wie {'{{ feld }}'} werden beim Druck ersetzt.",
muster: "Nummernkreis, zum Beispiel {'{SEQ:n}'}",
},
}
The output stays identical. A single @ the system escapes by itself while assembling.
Key conventions
So that the surfaces of the core find your labels, the keys are prescribed:
| Surface | Key |
|---|---|
| Navigation group | nav.groups.<key> |
| Navigation link | nav.links.<key> |
| Area | areas.<key> |
| Settings tile | settings.<module>.… |
| Search result type | search.types.<plural> |
| Dashboard widget | dashboardWidgets.w.<key> |
| Automation trigger | automation.trigger.<keyInLowerCamel> |
| Automation action | automation.action.<keyInLowerCamel> |
Manifests are data
Never call the translation function in the manifest. It is read at boot, long before a language is settled.
Everywhere the core needs a label you pass the key, not the text: labelKey, not label.
The same holds for option and status lists. Whoever resolves the label once and freezes it:
// Wrong: stays stuck in the old language after a language switch.
const status = [{ label: t('acmeShipping.status.open'), value: 'open' }]
The application switches the language without a reload. A frozen label notices nothing of it. Use the helpers from @/sdk instead, whose label resolves freshly on every access.
A new language
An additional language needs both halves: the contribution of the interface through the manifest and the language contract in the backend. Supplying only one of the two gives an application that falls back to the fallback language halfway.