feat(admin): localize plugin admin page labels via the shared Lingui instance#2013
feat(admin): localize plugin admin page labels via the shared Lingui instance#2013swissky wants to merge 3 commits into
Conversation
…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 detectedLatest commit: 4abaaf3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is the right change for the issue described in Discussion #2012. Running plugin adminPages labels through the shared Lingui instance fits EmDash’s Lingui-first admin UI conventions, and extracting resolvePluginPageLabel removes the duplicated label logic between the sidebar and command palette. The changeset is user-facing and correctly scopes the minor bump to @emdash-cms/admin.
What I checked:
- The diff, full changed files, and existing usages of
useLingui/i18nin the admin package. - Whether the macro
useLingui()exposesi18n(it does, confirmed by existing code inBackupSettings.tsx). - Lingui v5 catalog/message shape expectations.
Headline conclusion: the implementation is clean, but I have two concerns. The command palette’s useMemo relies on _ reference identity for locale reactivity, which is not a guaranteed Lingui behavior and can leave plugin labels stale after a locale switch. The docs example also uses an incorrect message-catalog value shape (string[] instead of string for simple messages), so copy-pasted plugin code would fail.
Both are fixable with small, targeted edits.
| const allNavItems = React.useMemo( | ||
| () => buildNavItems(manifest, userRole, translateDynamic), | ||
| [manifest, userRole, translateDynamic], |
There was a problem hiding this comment.
[needs fixing] The allNavItems memo depends on translateDynamic (a.k.a. _) for reactivity, but _ is a method on the stable i18n singleton and does not have a documented/provable identity change when the locale or catalog changes. React will re-render this component when I18nProvider updates, but useMemo will not recompute unless a dependency changes, so plugin labels can stay in the previous language after a locale switch.
Either include i18n.locale (and, for catalog merges that happen without a locale change, i18n.messages) in the dependency array, or translate the labels in the render/filter path where the non-memoized t macro already re-renders.
| const allNavItems = React.useMemo( | |
| () => buildNavItems(manifest, userRole, translateDynamic), | |
| [manifest, userRole, translateDynamic], | |
| const { _: translateDynamic, i18n } = useLinguiContext(); | |
| const [open, setOpen] = React.useState(false); | |
| const [query, setQuery] = React.useState(""); | |
| const navigate = useNavigate(); | |
| // Debounce the search query to avoid flickering on every keystroke | |
| const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS); | |
| const { data: user } = useCurrentUser(); | |
| const userRole = user?.role ?? 0; | |
| // Search content when debounced query is long enough | |
| const { data: searchResults, isFetching: isSearching } = useQuery({ | |
| queryKey: ["command-palette-search", debouncedQuery], | |
| queryFn: () => searchContent(debouncedQuery), | |
| enabled: debouncedQuery.length >= 2, | |
| staleTime: 30 * 1000, | |
| }); | |
| // Show loading while waiting for debounce or API response | |
| const isWaitingForDebounce = query.length >= 2 && query !== debouncedQuery; | |
| const isPendingSearch = isWaitingForDebounce || isSearching; | |
| // Build navigation items | |
| const allNavItems = React.useMemo( | |
| () => buildNavItems(manifest, userRole, translateDynamic), | |
| [manifest, userRole, translateDynamic, i18n.locale], | |
| ); |
There was a problem hiding this comment.
Fixed in 8dbae65 — i18n.locale is now in the dependency array as the documented locale-switch signal, with the _ rebind still covering catalog merges that arrive without a locale change (plugins load their catalogs asynchronously). Kept the memo rather than moving translation into the filter path so the labels aren't re-translated on every keystroke.
|
|
||
| // 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[]>> = { |
There was a problem hiding this comment.
[needs fixing] The catalog example declares values as string[] (Reports: ["Berichte"]), but Lingui v5 simple messages are strings, not arrays. A developer copying this code would pass the wrong shape to i18n.load and either fail type checking or get unexpected fallback behavior. Plural/ICU messages are still represented as strings in user-loaded catalogs and compiled to functions by Lingui; they should not be written as arrays here.
| const catalogs: Record<string, Record<string, string[]>> = { | |
| const catalogs: Record<string, Record<string, string>> = { | |
| de: { Reports: "Berichte", Settings: "Einstellungen" }, | |
| }; |
There was a problem hiding this comment.
Fixed in 8dbae65 — the example now uses plain-string messages (Reports: "Berichte"), which is the shape i18n.load() expects for uncompiled simple messages.
…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.
There was a problem hiding this comment.
This is the right follow-up for Discussion #2012. Running plugin adminPages labels through the shared Lingui instance fits EmDash’s Lingui-first admin conventions, and extracting resolvePluginPageLabel removes the duplicated sidebar/palette label logic.
I re-read the diff and the full changed files. Both concerns from the previous review are addressed:
- Command palette reactivity: the
useMemobuilding nav items now depends on bothtranslateDynamic(_from@lingui/react) andi18n.locale, so locale switches invalidate the memo explicitly even if_reference identity were to stay stable. - Docs catalog shape: the new
src/admin.tsxexample usesRecord<string, Record<string, string>>with plain string message values, matching Lingui v5’s catalog expectations.
The implementation is clean: the helper is unit-tested for translated / fallback / prettified-id cases, the changeset scopes the minor bump to @emdash-cms/admin, and the comments explain the reactivity contract clearly. I found no remaining logic bugs, regression risks, or AGENTS.md convention violations.
What does this PR do?
Plugin
adminPageslabels were rendered verbatim in the sidebar and command palette, so a fully localized admin still showed hard-coded English nav items for plugins — even when the plugin localizes everything inside its pages by merging a Lingui catalog into the sharedi18ninstance.This PR runs declared labels through
i18n._()at render time in both places, via a new sharedresolvePluginPageLabel()helper (deduplicates the previously copy-pasted label logic):i18n._()falls back to the msgid) — existing plugins are unaffected.Reactivity: the sidebar re-renders through
useLingui()'s context on every locale/catalog change; the palette's nav-item memo depends on the context's_binding, which the provider recreates on each change.The "React admin" plugin guide now documents the contract and shows the catalog-merge pattern (including re-merging on the locale switcher's catalog replacement).
Discussion: #2012
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
New unit tests for
resolvePluginPageLabel(translated label / untranslated fallback / plugin-id prettification); full@emdash-cms/adminsuite passes locally:Verified end to end against a real plugin that merges its own Lingui catalog: sidebar and ⌘K palette entries switch language together with the rest of the admin.