feat(admin): add module-level Lingui i18n string extraction#470
feat(admin): add module-level Lingui i18n string extraction#470ophirbucai wants to merge 27 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.
Add init.js import to render.ts in addition to setup.ts. While setup.ts runs first in local environments, CI may have different initialization order. Double initialization is safe (init.ts checks if locale is already set) and ensures i18n is ready before any test component renders. Tests pass locally but were failing in CI with "Cannot find element" errors, suggesting components weren't rendering properly due to i18n initialization timing.
Converting to React.createElement may have caused wrapper composition issues in CI. Reverting to JSX syntax while keeping the I18nProvider wrapper and init.js import. Tests pass locally with JSX. Import paths remain `.js` (TypeScript module resolution handles .tsx files correctly).
All test files were importing "../utils/render.js" but the file is render.tsx, causing import resolution failures in CI. Updated all 37 test file imports to use the correct .tsx extension. Also removed duplicate init.js import from render.tsx since setup.ts already handles i18n initialization before tests run. Fixes CI Browser Tests failures.
Tests in components/settings/ and components/users/ subdirectories need ../../utils/render.tsx (two levels up), not ../utils/render.tsx. Fixes CI import resolution error in AllowedDomainsSettings.test.tsx.
router.test.tsx is in the root tests/ directory, same level as utils/, so it should import "./utils/render.tsx" not "../utils/render.tsx". Fixes CI import error: "Failed to fetch dynamically imported module"
- Simplify init.ts comment to focus on behavior (empty messages → real catalog), not import sites (already documented at each import) - Remove redundant "(call at render time...)" from builder function comments (pattern is established, doesn't need repeating) - Add prettier-ignore to buildApiTokenScopes for readability Aligns with codebase pattern of documenting import discipline at call sites rather than in the module being imported.
…functions Converts all module-level constants using t`` to lazy builder functions called from useMemo hooks with i18n.locale dependency. This ensures translations are reactive and update when the locale changes. Changed: - ContentTypeEditor: SUPPORT_OPTIONS, SYSTEM_FIELDS - BlockMenu: blockTransforms - Widgets: BUILTIN_WIDGETS - AllowedDomainsSettings: ROLES, getRoleName - WelcomeModal: getRoleLabel - PortableTextEditor: defaultSlashCommands All builder functions now follow the same pattern as RoleBadge.tsx and ApiTokenSettings.tsx, ensuring consistent reactive i18n behavior.
Refactored buildRolesConfig to return a composite object with: - roles array for rendering Select options - roleLabels lookup object (Record<string, string>) for O(1) access - getRoleLabel function using the lookup This eliminates duplicate Object.fromEntries() calls at usage sites and provides consistent O(1) role label lookups. Renamed getRoleName to getRoleLabel for consistency with roleLabels naming.
The wildcard module declaration doesn't provide type safety for dynamic imports with template literals (messages is still typed as any). This file was originally added to support init.ts importing the compiled catalog, but init.ts now uses empty messages instead.
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?
What does this PR do?
This PR enables internationalization for module-level strings in the admin package — strings defined outside React components in constants, helper functions, and configuration objects.
The core challenge was that Lingui's t`` macro requires an initialized i18n instance, but module-level code executes before the App.tsx component mounts and calls i18n.loadAndActivate(). In Astro's island architecture with client:only="react", this timing issue was even more critical because islands hydrate independently.
Solution:
Files with module-level extractions:
This establishes a reusable pattern for module-level i18n extraction that maintains type safety and enables locale switching with proper UI re-renders.
Follows up #234
Type of change
Checklist
AI-generated code disclosure
Test verification
E2E tests:
Core package tests:
Manual UI testing:
String extraction verification:
Lint and typecheck: