diff --git a/.changeset/localized-plugin-page-labels.md b/.changeset/localized-plugin-page-labels.md new file mode 100644 index 0000000000..a82439dd6f --- /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. 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 5c6207e30b..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 @@ -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 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"; + +// 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..1a23026c31 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,12 @@ function filterNavItems( export function AdminCommandPalette({ manifest }: AdminCommandPaletteProps) { const { t } = useLingui(); + // 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(); @@ -316,7 +325,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, i18n.locale], + ); // 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..2561d52198 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -268,6 +268,29 @@ 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. 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"). + */ +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 +313,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 +410,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.