Translation Catalogs
A plugin registers labels, and those labels render in the admin of every site that installs it — including sites whose language you do not speak. The i18n slot on the descriptor is the only thing that makes the catalogs you translated those labels into reachable from that admin.
Overview
Section titled “Overview”Translating a plugin takes two halves: the catalogs, and the slot that serves them.
The first half is the catalogs themselves. Every user-visible string a plugin registers is a descriptor — an { id, message } pair — and each one needs a msgid in a .po per locale for a translator to fill in. plumix i18n compile then turns each filled-in .po into a runtime <locale>.mjs. Both steps are driven by the plugin’s own lingui.config.ts, which plumix i18n init writes with the helper from plumix/lingui, the one core, the admin, the editor and every first-party plugin compile with:
import { defineLinguiConfig } from "plumix/lingui";
export default defineLinguiConfig({ rootDir: import.meta.dirname });It has no locale list. The .po files under locales/ are the record of which locales the plugin ships, so adding a language is adding its .po. The helper applies the rest of the conventions: source locale en, PO without line numbers, and <rootDir>/locales/{locale} over src. A package with several translatable surfaces passes surfaces, a catalogPath and include dirs each.
A catalog must be named for its canonical BCP 47 tag: zh_CN.po or zh-cn.po fails the compile with the name to rename it to.
How a msgid gets into that .po depends on where the string lives. lingui extract collects what its Babel pass can see, which is the macros and <Trans> elements in the admin screens a plugin ships. It does not see a descriptor’s labels: those are plain object literals in server-side code that never goes through the macro pipeline, which is why every first-party plugin’s en.po is marked hand-authored. Both kinds land in the same catalog, so one slot covers both — and plumix i18n verify is what keeps source and catalog in step, because it scans for both shapes rather than relying on the extractor.
The second half is the i18n slot. It sits on the object you pass to definePlugin, beside setup:
import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", { i18n: { sourceLocale: "en", catalogPath: "./locales", }, setup: () => {},});At build time the site’s manifest is projected from every installed descriptor. A plugin carrying an i18n slot gets an entry under pluginI18n, built from the compiled <locale>.mjs files the build finds under its catalogPath, mapping each locale to a URL under /_plumix/admin/plugins/<id>/locales/<locale>.mjs (behind the site’s basePath, when it has one), and plumix build copies the matching .mjs out of the installed package to sit behind it. The admin fetches those URLs at boot and merges the messages into its active Lingui instance. A plugin with no i18n slot gets no entry, so nothing is copied, nothing is served, and the admin renders every one of its strings in the source locale.
Partial regional catalogs
Section titled “Partial regional catalogs”A regional catalog only needs the messages that differ. Compile fills every message a catalog lacks from its CLDR parent locales, then from the tag with its last subtag dropped, never crossing a script. A de-AT.po that translates two strings compiles with the rest from de.po; a zh-Hant.po never fills from a Simplified zh.po. A message no catalog on that chain translates compiles to its source string.
Quickstart
Section titled “Quickstart”Adding Ukrainian to a plugin whose strings are authored in English.
-
Scaffold the tooling. Run this from the plugin’s package root. It writes a
lingui.config.ts, addsi18n:extract,i18n:compileandi18n:checkscripts that runplumix i18n extract,compileandverify, adds aprepackscript that runsplumix i18n compile, and adds@lingui/clias a dev dependency — each step skipped if it is already there. It does not touch your descriptor; thei18nslot is yours to write.Terminal window pnpm plumix i18n init -
Create
locales/uk.po. The file is what adds the locale:plumix i18n compileproduces auk.mjsfor every.poit finds, with no config edit. -
Get the msgids into
locales/uk.po, and translate them. Run the extractor for anything macro-visible; for a descriptor’s labels, copy the msgids out ofen.po— the extractor does not see them. Then check the two agree.Terminal window pnpm i18n:extractpnpm i18n:check -
Ship the compiled catalogs.
filesdecides what the npm tarball carries, and the directory has to be in it. Naming the compiled files rather than the directory keeps the.posources out of the tarball, where nothing reads them.{"files": ["dist", "locales/*.mjs", "README.md", "LICENSE"],"exports": {".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },"./package.json": "./package.json"}}Packing compiles them: the
prepackscriptinitadded runsplumix i18n compile, sonpm packandnpm publishproduce the.mjsbefore building the tarball. A consumer never compiles your catalogs.
Install the published package into a site with uk enabled, run plumix build, and the admin renders the plugin’s labels in Ukrainian.
The slot
Section titled “The slot”Two keys, both required once the slot is present.
sourceLocale
Section titled “sourceLocale”The locale every msgid is authored in. It mirrors the en source locale plumix/lingui compiles with, and it lives on the manifest so the admin can resolve catalog paths without parsing a plugin’s lingui config at run time.
import type { PluginI18nSlot } from "plumix";
export const slot: PluginI18nSlot = { sourceLocale: "en", catalogPath: "./locales",};The projection always ships it. The production admin can’t compile a descriptor’s message at run time, so without a compiled catalog a string with a placeholder renders raw: Added {label} to the menu. The source catalog is therefore always in the URL map, whatever the site enables, and the admin falls back to it for any locale the plugin doesn’t translate. plumix build fails when a plugin’s locales/ lacks the compiled .mjs for its source locale.
catalogPath
Section titled “catalogPath”The directory holding the compiled <locale>.mjs files, relative to the plugin’s own package root — not to the site installing it. The build serves each locale it finds a <locale>.mjs for here, so the published tarball has to carry them. "./locales" is what every first-party plugin carrying a slot declares, and it lines up with the <rootDir>/locales/{locale} path plumix/lingui compiles to by default.
import type { PluginI18nSlot } from "plumix";
export const slot: PluginI18nSlot = { sourceLocale: "en", // Resolved inside the installed @plumix/plugin-recipes package, // never inside the site that installs it. catalogPath: "./locales",};Finding that package root is a require.resolve of "<package>/package.json" from the site’s own root, and the package name is derived from the descriptor id rather than declared. Two conventions are tried, in order: @plumix/plugin-<id>, then plumix-plugin-<id>. An id containing _ also gets a hyphenated candidate for each, so a descriptor id of audit_log still resolves to @plumix/plugin-audit-log.
Two consequences follow, and both are build failures rather than degraded output:
- The package name has to match the descriptor id under one of those conventions. A plugin published as
recipes-for-plumixwith a descriptor id ofrecipesresolves to nothing. package.jsonhas to be an export. The lookup asks for that subpath by name, so anexportsmap without"./package.json": "./package.json"makes the package unresolvable even when the name is right.
An absolute catalogPath is honoured verbatim, skipping resolution entirely. It is there for a plugin that is not installed as a package at all, and it is not portable — do not reach for it to work around a name that does not resolve.
Site locales narrow it, nothing widens it
Section titled “Site locales narrow it, nothing widens it”The projection serves each locale the site has enabled in its own i18n config from the closest catalog the plugin ships: a plugin that ships de serves a de-AT site, and an enabled locale with no close catalog gets the source catalog. Shipping a catalog for a locale the site has not enabled emits no URL, adds no weight to the manifest and does not put a language in the admin’s dropdown.
A site that configures no i18n resolves to ["en"] rather than to nothing, so the projection leaves only the source locale. Until a site enables a second locale, a plugin ships its source catalog alone, which is the state most sites installing your plugin are in.
Keeping the two lists honest
Section titled “Keeping the two lists honest”plumix i18n verify compares the descriptors in src against the msgids in locales/*.po and exits non-zero on drift in either direction, which catches a string added without an extract and a catalog entry left behind after a string was deleted. plumix i18n init wires it up as i18n:check; run it in CI.
Related
Section titled “Related”The slot sits on the descriptor object Overview covers, and the strings it translates are the labels registered in Content Modelling and Entry Types. The site-side half of the intersection — which locales a site enables, and the default one — is a slot in Configuration.
plumix build is what stages a catalog and what fails on a missing one; Dev Server covers the loop that build ends.
Next steps
Section titled “Next steps”Every first-party plugin is a working reference for this. Blog covers what one of them registers, and those labels are what its five shipped catalogs translate — four of them, once English drops out.
Two unwritten pages bear on this one. Publishing a Plugin, in this section, covers taking a local plugin to npm, where the files and exports fields above are half the work. Internationalization, under Going Further, covers the site-side surface — Label, the formatters, and the locales a site enables.