Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/localized-plugin-page-labels.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string>> = {
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:
Expand Down
28 changes: 20 additions & 8 deletions packages/admin/src/components/AdminCommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -124,7 +126,11 @@ async function searchContent(query: string): Promise<SearchResponse> {
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",
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down
32 changes: 25 additions & 7 deletions packages/admin/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/admin/tests/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
BYLINE_SCHEMA_NAV_ITEM,
filterNavItemsByRole,
resolveNavIcon,
resolvePluginPageLabel,
toPhosphorIconName,
} from "../../src/components/Sidebar";
import { render } from "../utils/render.tsx";
Expand Down Expand Up @@ -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.
Expand Down
Loading