From 2516a94efc900225aaff5a8640d217670818059f Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:23:31 +0200 Subject: [PATCH 1/3] feat(admin): localize plugin admin page labels via the shared Lingui instance Plugin adminPages labels were rendered verbatim in the sidebar and command palette, so a fully localized admin still showed hard-coded English nav items for plugins. Run declared labels through i18n._() at render time: plugins that merge a Lingui catalog into the shared instance (English label as msgid) get localized navigation, and labels without a catalog entry fall back to the literal string, keeping existing plugins unchanged. --- .changeset/localized-plugin-page-labels.md | 5 ++++ .../creating-native-plugins/react-admin.mdx | 22 ++++++++++++++ .../src/components/AdminCommandPalette.tsx | 27 ++++++++++++----- packages/admin/src/components/Sidebar.tsx | 29 ++++++++++++++----- .../admin/tests/components/Sidebar.test.tsx | 25 ++++++++++++++++ 5 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 .changeset/localized-plugin-page-labels.md diff --git a/.changeset/localized-plugin-page-labels.md b/.changeset/localized-plugin-page-labels.md new file mode 100644 index 0000000000..0257c98f37 --- /dev/null +++ b/.changeset/localized-plugin-page-labels.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Plugin admin page labels in the sidebar and command palette are now run through the admin's Lingui instance, so plugins that load a message catalog (with the English label as the message id) get localized navigation. Labels without a catalog entry render unchanged. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx index 5c6207e30b..5ffd17627d 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx @@ -83,6 +83,28 @@ admin: { } ``` +Declare labels in English. The admin runs them through its shared [Lingui](https://lingui.dev/) instance before rendering the sidebar and command palette, so a plugin that loads its own message catalog — with the English label as the message id — gets localized navigation for free. Labels without a catalog entry render as declared. + +```typescript title="src/admin.tsx" +import { i18n } from "@lingui/core"; + +// Merge the plugin's compiled catalog into the admin's i18n instance. +// "Reports" now renders as "Berichte" when the admin locale is German. +const catalogs: Record> = { + de: { Reports: ["Berichte"], Settings: ["Einstellungen"] }, +}; + +function mergeCatalog() { + const messages = catalogs[i18n.locale]; + if (messages && !("Reports" in i18n.messages)) i18n.load(i18n.locale, messages); +} + +mergeCatalog(); +// The admin's locale switcher *replaces* the catalog on change, so re-merge. +// The sentinel check above keeps this from recursing (load() fires "change"). +i18n.on("change", mergeCatalog); +``` + ### Page component The following component reads and saves settings through the plugin API hook: diff --git a/packages/admin/src/components/AdminCommandPalette.tsx b/packages/admin/src/components/AdminCommandPalette.tsx index 5d94007264..1f34bd8cd3 100644 --- a/packages/admin/src/components/AdminCommandPalette.tsx +++ b/packages/admin/src/components/AdminCommandPalette.tsx @@ -8,6 +8,7 @@ import { CommandPalette } from "@cloudflare/kumo"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; +import { useLingui as useLinguiContext } from "@lingui/react"; import { useLingui } from "@lingui/react/macro"; import { SquaresFour, @@ -30,6 +31,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { apiFetch, type AdminManifest } from "../lib/api/client.js"; import { useCurrentUser } from "../lib/api/current-user"; +import { resolvePluginPageLabel } from "./Sidebar"; /** Subset of manifest fields used by the palette (matches `Shell` props shape). */ type CommandPaletteManifest = { @@ -124,7 +126,11 @@ async function searchContent(query: string): Promise { return body.data; } -function buildNavItems(manifest: CommandPaletteManifest, userRole: number): NavItem[] { +function buildNavItems( + manifest: CommandPaletteManifest, + userRole: number, + translateLabel: (id: string) => string, +): NavItem[] { const items: NavItem[] = [ { id: "dashboard", @@ -253,12 +259,9 @@ function buildNavItems(manifest: CommandPaletteManifest, userRole: number): NavI if (config.enabled === false) continue; if (config.adminPages && config.adminPages.length > 0) { for (const page of config.adminPages) { - const label = - page.label || - pluginId - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); + // Same treatment as the sidebar: declared labels go through the + // shared i18n instance so plugin catalogs can localize them. + const label = resolvePluginPageLabel(page.label, pluginId, translateLabel); items.push({ id: `plugin-${pluginId}-${page.path}`, @@ -292,6 +295,11 @@ function filterNavItems( export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) { const { t } = useLingui(); + // `_` (not `i18n`) is the nav-items memo dependency: the i18n instance is + // a stable singleton, while the provider re-binds `_` on every locale or + // catalog change — exactly the invalidation the plugin labels need. The + // macro useLingui() omits `_`, so it comes from the runtime hook. + const { _: translateDynamic } = useLinguiContext(); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const navigate = useNavigate(); @@ -316,7 +324,10 @@ export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) { const isPendingSearch = isWaitingForDebounce || isSearching; // Build navigation items - const allNavItems = React.useMemo(() => buildNavItems(manifest, userRole), [manifest, userRole]); + const allNavItems = React.useMemo( + () => buildNavItems(manifest, userRole, translateDynamic), + [manifest, userRole, translateDynamic], + ); // Filter nav items based on query const filteredNavItems = React.useMemo( diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index 8f3532dbe2..4e27229dbb 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -268,6 +268,26 @@ function NavIcon({ icon: Icon, className }: { icon: React.ElementType; className ); } +/** + * Resolve the display label for a plugin admin page (sidebar + command + * palette). Declared labels are run through the shared Lingui instance: + * plugins that load their own catalog — with the English label as msgid — + * get localized nav items, and labels without a catalog entry fall back to + * the literal string. Pages without a label prettify the plugin id + * ("my-shop" → "My Shop"). + */ +export function resolvePluginPageLabel( + label: string | undefined, + pluginId: string, + translate: (id: string) => string, +): string { + if (label) return translate(label); + return pluginId + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + /** Resolves a nav item's route path by substituting $param placeholders. */ function resolveItemPath(item: NavItem): string { let path = item.to; @@ -290,7 +310,7 @@ function isItemActive(itemPath: string, currentPath: string): boolean { * Admin sidebar navigation using kumo's Sidebar compound component. */ export function SidebarNav({ manifest }: SidebarNavProps) { - const { t } = useLingui(); + const { t, i18n } = useLingui(); const location = useLocation(); const currentPath = location.pathname; const pluginAdmins = usePluginAdmins(); @@ -387,12 +407,7 @@ export function SidebarNav({ manifest }: SidebarNavProps) { const isBlocksMode = config.adminMode === "blocks"; for (const page of config.adminPages) { if (!isBlocksMode && !resolvePluginPagePath(pluginPages, page.path)) continue; - const label = - page.label || - pluginId - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); + const label = resolvePluginPageLabel(page.label, pluginId, (id) => i18n._(id)); pluginItems.push({ to: `/plugins/${pluginId}${page.path}`, label, diff --git a/packages/admin/tests/components/Sidebar.test.tsx b/packages/admin/tests/components/Sidebar.test.tsx index ba128befaa..e7de19e8fa 100644 --- a/packages/admin/tests/components/Sidebar.test.tsx +++ b/packages/admin/tests/components/Sidebar.test.tsx @@ -31,6 +31,7 @@ import { BYLINE_SCHEMA_NAV_ITEM, filterNavItemsByRole, resolveNavIcon, + resolvePluginPageLabel, toPhosphorIconName, } from "../../src/components/Sidebar"; import { render } from "../utils/render.tsx"; @@ -97,6 +98,30 @@ describe("filterNavItemsByRole", () => { }); }); +describe("resolvePluginPageLabel", () => { + // Simulates a plugin that loaded its Lingui catalog into the shared i18n + // instance: known msgids translate, unknown ones return the msgid itself + // (exactly i18n._'s fallback behavior). + const translate = (id: string) => (id === "Orders" ? "Bestellungen" : id); + + it("translates a declared label through the shared i18n instance", () => { + expect(resolvePluginPageLabel("Orders", "my-shop", translate)).toBe("Bestellungen"); + }); + + it("falls back to the literal label when no catalog entry exists", () => { + // Plugins without a Lingui catalog must render exactly what they + // declared — the identity lookup keeps this fully backwards compatible. + expect(resolvePluginPageLabel("Products", "my-shop", translate)).toBe("Products"); + }); + + it("prettifies the plugin id when no label is declared (untranslated)", () => { + // The fallback is derived from the package id, not author-provided + // English — never run it through the catalog. + expect(resolvePluginPageLabel(undefined, "my-shop", translate)).toBe("My Shop"); + expect(resolvePluginPageLabel(undefined, "orders", translate)).toBe("Orders"); + }); +}); + describe("toPhosphorIconName", () => { it("converts kebab/snake/space names to PascalCase (the lazy-path key)", () => { // Any Phosphor icon is reachable by its own kebab name. From 4abaaf3f43890bd91340ce2b3088bfdbeea299af Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:29:36 +0200 Subject: [PATCH 2/3] docs(admin): document that plugin labels share the admin's Lingui catalog --- .changeset/localized-plugin-page-labels.md | 2 +- .../docs/plugins/creating-native-plugins/react-admin.mdx | 2 +- packages/admin/src/components/Sidebar.tsx | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/localized-plugin-page-labels.md b/.changeset/localized-plugin-page-labels.md index 0257c98f37..a82439dd6f 100644 --- a/.changeset/localized-plugin-page-labels.md +++ b/.changeset/localized-plugin-page-labels.md @@ -2,4 +2,4 @@ "@emdash-cms/admin": minor --- -Plugin admin page labels in the sidebar and command palette are now run through the admin's Lingui instance, so plugins that load a message catalog (with the English label as the message id) get localized navigation. Labels without a catalog entry render unchanged. +Plugin admin page labels in the sidebar and command palette are now run through the admin's Lingui instance. Plugins that load a message catalog (with the English label as the message id) get localized navigation, and labels matching one of the admin's own messages (such as "Settings") follow the admin locale automatically. Labels without a catalog entry render unchanged. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx index 5ffd17627d..3962c9c378 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx @@ -83,7 +83,7 @@ admin: { } ``` -Declare labels in English. The admin runs them through its shared [Lingui](https://lingui.dev/) instance before rendering the sidebar and command palette, so a plugin that loads its own message catalog — with the English label as the message id — gets localized navigation for free. Labels without a catalog entry render as declared. +Declare labels in English. The admin runs them through its shared [Lingui](https://lingui.dev/) instance before rendering the sidebar and command palette, so a plugin that loads its own message catalog — with the English label as the message id — gets localized navigation for free. Labels that match one of the admin's own messages (`Settings`, `Dashboard`, …) pick up the admin's translations even without a plugin catalog; labels with no catalog entry anywhere render as declared. ```typescript title="src/admin.tsx" import { i18n } from "@lingui/core"; diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index 4e27229dbb..2561d52198 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -272,7 +272,10 @@ function NavIcon({ icon: Icon, className }: { icon: React.ElementType; className * Resolve the display label for a plugin admin page (sidebar + command * palette). Declared labels are run through the shared Lingui instance: * plugins that load their own catalog — with the English label as msgid — - * get localized nav items, and labels without a catalog entry fall back to + * get localized nav items. The catalog is shared with the admin, so common + * labels like "Settings" pick up the admin's own translations even without + * a plugin catalog (deliberate: a localized admin shouldn't show stray + * English nav items). Labels with no catalog entry anywhere fall back to * the literal string. Pages without a label prettify the plugin id * ("my-shop" → "My Shop"). */ From 8dbae6591451e316f0193d4819937cd2fd7aa804 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:45:47 +0200 Subject: [PATCH 3/3] fix(admin): add i18n.locale to nav-items memo deps; fix docs catalog shape Review follow-ups: the palette memo now also depends on i18n.locale (the documented locale-switch signal) rather than relying solely on the context _ rebind, and the docs example uses plain-string catalog messages instead of the compiled-token array shape. --- .../plugins/creating-native-plugins/react-admin.mdx | 4 ++-- .../admin/src/components/AdminCommandPalette.tsx | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx index 3962c9c378..a44c92c934 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx @@ -90,8 +90,8 @@ import { i18n } from "@lingui/core"; // Merge the plugin's compiled catalog into the admin's i18n instance. // "Reports" now renders as "Berichte" when the admin locale is German. -const catalogs: Record> = { - de: { Reports: ["Berichte"], Settings: ["Einstellungen"] }, +const catalogs: Record> = { + de: { Reports: "Berichte", Settings: "Einstellungen" }, }; function mergeCatalog() { diff --git a/packages/admin/src/components/AdminCommandPalette.tsx b/packages/admin/src/components/AdminCommandPalette.tsx index 1f34bd8cd3..1a23026c31 100644 --- a/packages/admin/src/components/AdminCommandPalette.tsx +++ b/packages/admin/src/components/AdminCommandPalette.tsx @@ -295,11 +295,12 @@ function filterNavItems( export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) { const { t } = useLingui(); - // `_` (not `i18n`) is the nav-items memo dependency: the i18n instance is - // a stable singleton, while the provider re-binds `_` on every locale or - // catalog change — exactly the invalidation the plugin labels need. The - // macro useLingui() omits `_`, so it comes from the runtime hook. - const { _: translateDynamic } = useLinguiContext(); + // The runtime hook (the macro useLingui() omits `_`): `_` translates the + // dynamic plugin labels, and both it and `i18n.locale` invalidate the + // nav-items memo below. `i18n.locale` is the documented signal for locale + // switches; the `_` rebind covers catalog merges that arrive without a + // locale change (plugins load their catalogs asynchronously). + const { _: translateDynamic, i18n } = useLinguiContext(); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const navigate = useNavigate(); @@ -326,7 +327,7 @@ export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) { // Build navigation items const allNavItems = React.useMemo( () => buildNavItems(manifest, userRole, translateDynamic), - [manifest, userRole, translateDynamic], + [manifest, userRole, translateDynamic, i18n.locale], ); // Filter nav items based on query