feat(admin): add module-level Lingui i18n string extraction#470
feat(admin): add module-level Lingui i18n string extraction#470ascorbic merged 49 commits intoemdash-cms:mainfrom
Conversation
Creates a dedicated init module that pre-initializes Lingui's i18n instance with English locale before any other modules execute. This prevents "no locale set" errors when module-level t`` macros evaluate. The init module is imported in both the admin package entry point and the Astro admin route to ensure early initialization regardless of Astro's island hydration order. Added src/locales/init.ts to tsdown entry array so it builds to dist/locales/init.js.
Wraps user-facing strings in module-level constants and helper functions with the t\`\` macro from @lingui/core/macro. These strings are defined outside React components but are called lazily within useMemo hooks or during render, ensuring they evaluate after i18n initialization. Changes include: - AdminCommandPalette: buildNavItems function - PortableTextEditor: block type definitions and embed config - BlockMenu, Widgets: block/widget labels and descriptions - ContentTypeEditor: field labels and descriptions - ApiTokenSettings: expiry options - api-tokens.ts: scope labels and descriptions - RoleBadge: role names and descriptions - WelcomeModal: role names - AllowedDomainsSettings: role names - MediaPickerModal, MediaLibrary: tab labels All wrapped strings will be extracted by lingui extract in the next commit. Components using these strings have i18n.locale in their useMemo dependencies to trigger re-render on locale change.
Runs lingui extract to discover and catalog all newly wrapped t\`\` strings from the previous commit. Adds 130+ new message IDs to both English and German catalogs. English catalog includes auto-filled translations (msgid = msgstr for en). German catalog entries are marked for translation (empty msgstr). Generated by: pnpm locale:extract
…rModal These components use runtime t from useLingui() inside useMemo, which is standard React i18n, not module-level extraction. Removing them from this PR as they're out of scope.
Removed 'Library' string that was erroneously included from MediaLibrary and MediaPickerModal (runtime t usage, not module-level).
|
Scope checkThis PR changes 1,054 lines across 17 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@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-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Enables i18n extraction/usage for module-level (non-component) admin strings by pre-initializing Lingui and wrapping many constant/config strings with t\`` to prevent early-evaluation crashes in Astro island hydration.
Changes:
- Add an
@emdash-cms/admin/locales/initside-effect module and ensure it’s imported early (admin entry + Astro route). - Expand the admin build entries to emit
locales/initand add typing formessages.mjsimports. - Wrap a large set of previously hardcoded labels/descriptions with Lingui macros and update PO catalogs.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/astro/routes/admin.astro | Imports i18n pre-init before client modules load; loads resolved-locale messages for the island |
| packages/admin/tsdown.config.ts | Adds src/locales/init.ts as a build entry |
| packages/admin/src/locales/init.ts | Pre-initializes Lingui i18n (English) via side-effect import |
| packages/admin/src/locales/en/messages.po | Adds newly extracted English msgids |
| packages/admin/src/locales/de/messages.po | Adds newly extracted German entries (empty msgstr) |
| packages/admin/src/locales/en/messages.mjs.d.ts | Adds a wildcard module declaration for compiled messages.mjs |
| packages/admin/src/lib/api/api-tokens.ts | Makes API token scope labels/descriptions translatable |
| packages/admin/src/index.ts | Ensures init module is imported first |
| packages/admin/src/components/* | Wraps multiple module-level labels/descriptions with t\`` and adds locale deps in a couple of memoized builders |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/admin/src/components/settings/AllowedDomainsSettings.tsx
Outdated
Show resolved
Hide resolved
Converted module-level constants with t\`\` calls to builder functions that return the config on demand. This ensures translations execute at render time (when locale is current) rather than at import time (frozen to pre-init locale). Changed: - RoleBadge: ROLE_CONFIG -> buildRoleConfig(), called in useMemo - api-tokens: API_TOKEN_SCOPES -> buildApiTokenScopes(), called in useMemo - ApiTokenSettings: EXPIRY_OPTIONS -> buildExpiryOptions(), called in useMemo All components using these now have i18n.locale in their useMemo dependencies, ensuring labels/descriptions update when locale changes.
5360212 to
ca9620e
Compare
Pre-initialization only needs to activate a locale, not load real messages. App.tsx loads the real English catalog via i18n.loadAndActivate() in useEffect, and since module-level t calls execute lazily from builder functions (called in useMemo), they'll use the real messages. This eliminates the hard dependency on compiled messages.mjs, allowing dev workflows (pnpm dev, tests, fresh clones) to work without requiring locale:compile to run first. Bundle size: init.js reduced from 5.26 kB to 0.77 kB.
ca9620e to
5097436
Compare
Components using useLingui() (introduced by module-level i18n) require I18nProvider context in tests. Instead of wrapping each test manually, centralize i18n setup: 1. Convert render.tsx to render.ts (React.createElement, no JSX) 2. Automatically wrap components in I18nProvider 3. Update all test imports: vitest-browser-react → ../utils/render.js 4. Centralize init.js import in setup.ts (runs before all tests) This ensures all tests have i18n context without explicit wrappers.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 57 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/admin/src/lib/api/index.ts:264
API_TOKEN_SCOPESwas previously exported from the publiclib/apibarrel, but this change removes it and replaces it withbuildApiTokenScopes. That’s a breaking API change for downstream consumers importingAPI_TOKEN_SCOPES. Consider keeping a backwards-compatible export (e.g. re-exportingAPI_TOKEN_SCOPESwith a clear deprecation path) or add the appropriate changeset/versioning note to reflect the breaking change.
// API Tokens
export {
type ApiTokenInfo,
type ApiTokenCreateResult,
type CreateApiTokenInput,
buildApiTokenScopes,
fetchApiTokens,
createApiToken,
revokeApiToken,
} from "./api-tokens.js";
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ascorbic
left a comment
There was a problem hiding this comment.
Hey! Great work here. I think the builder pattern isn't needed though. A simpler and more idiomatic approach would be to use lazy translations. This solves exactly the problem you're dealing with here, but without the boilerplate of builders and useMemo everywhere.
This does also highlight the fact we have lots of duplicated copies of the role labels. Is there somewhere these could be extracted so we're not translating them multiple times?
…ptor Use Lingui msg for static copy and a NavItemTitle union for manifest labels that cannot use dynamic msg ids. Result groups keep MessageDescriptor labels resolved with t() from useLingui().
…to msg Define SUPPORT_OPTIONS and SYSTEM_FIELDS with MessageDescriptor labels and descriptions; resolve in the editor and SystemFieldRow via useLingui t(). ContentTypeEditor tests import the shared render helper so I18nProvider wraps useLingui.
Use MessageDescriptor for all slash titles, descriptions, and categories.
Plugin rows use interpolated msg for label, optional description, and
"Embed a {0}" fallback. Tests use shared render for I18nProvider.
Module-level msg for roles, scope copy, titles, and actions; interpolated welcome title with first name. Tests use shared render for I18nProvider.
BUILTIN_WIDGETS label and description use module-level msg; Widgets resolves them with useLingui t() for palette rows and drag payload labels.
Use `title: string | MessageDescriptor` on `NavItem` and resolve with inline `typeof` checks for filtering and palette rows. Removes the old discriminated wrapper and helper indirection (KISS, easier review).
Built-in palette items omit a hardcoded English `title` on the input; the drag payload uses `t(item.label)` so persisted defaults match the palette. Normalize the Widgets test render import path.
Use MessageDescriptor | string for slash title/description; built-ins stay on
msg, plugin rows keep API strings and use t(msg`Embed a ${block.label}`) only
for the fallback. Resolve menu and filter text with inline typeof checks.
Editor tests import the shared render harness without a file extension so
useLingui runs under I18nProvider (slash-menu included).
Replace buildBlockTransforms() (macro t) with module-level blockTransforms using msg for transform labels; BlockMenu resolves labels with t() in the Turn into submenu only. Leave main menu copy as literals (lazy-migration PR scope). block-menu tests use shared render; transforms test imports the exported blockTransforms array.
Use module-level msg for role descriptors; resolve with useLingui in UI. Tests use shared render harness for I18nProvider.
Module-level msg for expiry options, scope labels (SCOPE_UI), and UI copy; resolve with useLingui. Scope values still come from API_TOKEN_SCOPES in api-tokens. Pass pre-translated expiry map into CreateTokenForm for Select items. Catalog extract for en/de.
- Add roleDefinitions (ROLE_ENTRIES, getRoleConfig) for msg + badge colors - Add useRolesConfig: roleLabels, getRoleLabel, pre-resolved roles rows - Add useAllowedDomainsRolesConfig for default-role selects (cap at Editor) - Wire AllowedDomainsSettings, UserList, UserDetail, InviteUserModal, users route - RoleBadge uses getRoleConfig only; barrel exports useRolesConfig + getRoleConfig - User invite/detail tests use shared I18n render harness - No locale .po in this slice (per migration plan cadence)
Replace API_TOKEN_SCOPES label/description duplicates with API_TOKEN_SCOPE_VALUES + ApiTokenScopeValue; UI copy stays in ApiTokenSettings SCOPE_UI (msg). Re-export from lib/api index.
…est to ensure no drift Transform API token scopes from an array to an object for improved readability and maintainability. Update the type definition to reflect the new structure while preserving existing functionality.
Ensures all browser tests have i18n context initialized.
What does this PR do?
Wraps module-level strings in the admin UI with Lingui's lazy translation pattern, enabling i18n for strings declared outside React components.
The challenge: Module-level strings (constants, helper functions, config objects) can't use the
tmacro directly because it requires an active i18n instance fromuseLingui(), which is only available inside React components.Solution: Use Lingui's
msgmacro for lazy message descriptors:msg`placeholder`(returns aMessageDescriptor)t(descriptor)fromuseLingui()at render timeFiles with module-level extractions:
Follows up #234
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runpnpm locale:extracthas been run (if applicable)AI-generated code disclosure
Screenshots / test output
Admin browser tests: 725/725 passing