Skip to content

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.

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.

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.

Adding Ukrainian to a plugin whose strings are authored in English.

  1. Scaffold the tooling. Run this from the plugin’s package root. It writes a lingui.config.ts, adds i18n:extract, i18n:compile and i18n:check scripts that run plumix i18n extract, compile and verify, adds a prepack script that runs plumix i18n compile, and adds @lingui/cli as a dev dependency — each step skipped if it is already there. It does not touch your descriptor; the i18n slot is yours to write.

    Terminal window
    pnpm plumix i18n init
  2. Create locales/uk.po. The file is what adds the locale: plumix i18n compile produces a uk.mjs for every .po it finds, with no config edit.

  3. 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 of en.po — the extractor does not see them. Then check the two agree.

    Terminal window
    pnpm i18n:extract
    pnpm i18n:check
  4. Ship the compiled catalogs. files decides what the npm tarball carries, and the directory has to be in it. Naming the compiled files rather than the directory keeps the .po sources 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 prepack script init added runs plumix i18n compile, so npm pack and npm publish produce the .mjs before 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.

Two keys, both required once the slot is present.

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.

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-plumix with a descriptor id of recipes resolves to nothing.
  • package.json has to be an export. The lookup asks for that subpath by name, so an exports map 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.

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.

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.

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.

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.