feat(admin): add module-level Lingui i18n string extraction#470
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.
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.
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.
…ms#470) * Add i18n pre-initialization to fix module-level macro timing 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. * Apply Lingui t\`\` macro to module-level strings 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. * Extract module-level strings to locale catalogs 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 * Add TypeScript declarations for compiled message catalogs * Format code with oxfmt and prettier * Revert non-module-level translations from MediaLibrary and MediaPickerModal 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. * Re-extract locale catalogs after removing non-module-level strings Removed 'Library' string that was erroneously included from MediaLibrary and MediaPickerModal (runtime t usage, not module-level). * Clarify init.ts must be imported in both index.ts and admin.astro * Suppress lint warnings for intentional side-effect imports * Simplify init.ts import comment in index.ts * Add .js extension to init module import in admin.astro * Fix eager module-level t translations to be lazy/reactive 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. * style: format * Update locale catalog line number references after refactor * Use empty messages in init.ts to avoid dependency on compiled catalogs 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. * test: provide I18nProvider to all tests via custom render utility 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. * style: format * test: ensure i18n init in render utility for CI reliability 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. * test: revert render utility to JSX syntax 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). * test: fix render utility import paths to use .tsx extension 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. * test: fix render import paths for tests in subdirectories 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. * test: fix router.test.tsx import path for render utility 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" * chore: simplify i18n comments and builder function docs - 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. * refactor(admin): convert module-level i18n constants to lazy builder 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. * style: format * refactor(admin): optimize role lookup in AllowedDomainsSettings 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. * chore(admin): remove unused messages.mjs.d.ts declaration file 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. * refactor(admin): remove i18n initialization and clean up imports * refactor(admin): migrate AdminCommandPalette to msg and MessageDescriptor 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(). * refactor(admin): migrate ContentTypeEditor support and system fields 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. * refactor(admin): PortableTextEditor slash commands use msg and t 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. * refactor(admin): migrate WelcomeModal to msg and useLingui Module-level msg for roles, scope copy, titles, and actions; interpolated welcome title with first name. Tests use shared render for I18nProvider. * refactor(admin): migrate Widgets built-in palette to msg and useLingui BUILTIN_WIDGETS label and description use module-level msg; Widgets resolves them with useLingui t() for palette rows and drag payload labels. * refactor(admin): simplify command palette nav item titles 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). * fix(admin): align built-in widget default title with localized label 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. * refactor(admin): complete PortableTextEditor slash i18n and tests 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). * refactor(admin): BlockMenu block transforms use msg + lazy t 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. * refactor(admin): lazy Lingui pattern for AllowedDomainsSettings Use module-level msg for role descriptors; resolve with useLingui in UI. Tests use shared render harness for I18nProvider. * refactor(admin): lazy Lingui for ApiTokenSettings 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. * refactor(admin): centralize role labels with useRolesConfig - 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) * refactor(admin): API token scopes as values-only in api-tokens 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. * refactor(admin): migrate ApiTokenSettings and scopes map, include a test 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. * chore: import order formatting * chore(i18n): update German and English translations for various components * style: format * refactor(InviteUserModal, UserDetails): reuse roleLabels in select items * fix: initialize i18n in test setup and use I18nProvider wrapper Ensures all browser tests have i18n context initialized. * style: add void to floating render promises --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
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