Skip to content

feat(admin): add module-level Lingui i18n string extraction#470

Open
ophirbucai wants to merge 27 commits intoemdash-cms:mainfrom
ophirbucai:i18n/module-level-extractions
Open

feat(admin): add module-level Lingui i18n string extraction#470
ophirbucai wants to merge 27 commits intoemdash-cms:mainfrom
ophirbucai:i18n/module-level-extractions

Conversation

@ophirbucai
Copy link
Copy Markdown
Contributor

@ophirbucai ophirbucai commented Apr 11, 2026

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:

  1. Created a dedicated locales/init.ts module that pre-initializes i18n with the English locale before any other modules load
  2. Imported this init module in both the admin package entry point (index.ts) and the Astro admin route (admin.astro) to ensure early initialization regardless of hydration order
  3. Wrapped 130+ module-level strings with t`` from @lingui/core/macro across 10 files
  4. Ensured components using these strings have i18n.locale in their useMemo dependencies for reactivity

Files with module-level extractions:

  • AdminCommandPalette: Navigation items
  • PortableTextEditor: Block type definitions, embed config
  • BlockMenu, Widgets: Block/widget labels and descriptions
  • ContentTypeEditor: Field labels and descriptions
  • ApiTokenSettings: Expiry options
  • api-tokens.ts: API token scope labels
  • RoleBadge, WelcomeModal, AllowedDomainsSettings: User role names

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

  • Refactor (no behavior change)
  • Translation

Checklist

  • I have read CONTRIBUTING.md
  • `pnpm typecheck` passes
  • `pnpm lint` passes
  • `pnpm test` passes (or targeted tests for my change)
  • `pnpm format` has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation and `pnpm locale:extract` has been run (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: N/A

AI-generated code disclosure

  • This PR includes AI-generated code

Test verification

E2E tests:

  • Full e2e test suite passes after pre-initialization fix
  • Previously failing test (`expect(locator).toContainText("Sign in")`) now passes, confirming module-level t`` macros no longer break page hydration

Core package tests:

  • `pnpm --filter @emdash-cms/core test` passes

Manual UI testing:

  • Admin UI loads correctly at `/_emdash/admin`
  • Command palette (⌘K) displays correctly with all translated navigation items
  • Locale switching (English ↔ German via locale switcher) triggers proper UI re-renders
  • Verified reactivity by temporarily adding German translations — UI updated immediately on locale switch, confirming i18n.locale dependency tracking works correctly

String extraction verification:

  • `pnpm locale:extract` correctly extracted 133 new strings
  • English catalog (en/messages.po) shows all extracted strings with auto-filled translations
  • German catalog (de/messages.po) shows all extracted strings marked for translation (empty msgstr)
  • String count matches 1:1 between code changes and extracted messages

Lint and typecheck:

  • `pnpm typecheck` passes
  • `pnpm lint` passes (warnings for side-effect import ignored as they are indeed side-effects - intentionally so)
  • `pnpm format` applied

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).
Copilot AI review requested due to automatic review settings April 11, 2026 23:08
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 11, 2026

⚠️ No Changeset found

Latest commit: e8e0e21

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 11, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 11, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@470

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@470

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@470

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@470

emdash

npm i https://pkg.pr.new/emdash@470

create-emdash

npm i https://pkg.pr.new/create-emdash@470

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@470

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@470

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@470

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@470

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@470

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@470

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@470

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@470

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@470

commit: e8e0e21

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/init side-effect module and ensure it’s imported early (admin entry + Astro route).
  • Expand the admin build entries to emit locales/init and add typing for messages.mjs imports.
  • 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.

ophirbucai and others added 4 commits April 12, 2026 02:18
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.
@ophirbucai ophirbucai force-pushed the i18n/module-level-extractions branch from 5360212 to ca9620e Compare April 11, 2026 23:35
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.
@ophirbucai ophirbucai force-pushed the i18n/module-level-extractions branch from ca9620e to 5097436 Compare April 11, 2026 23:36
ophirbucai and others added 2 commits April 12, 2026 02:53
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.
ophirbucai and others added 9 commits April 12, 2026 03:00
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.
@ophirbucai ophirbucai changed the title Enable module-level i18n extractions with pre-initialization feat(admin): add module-level Lingui i18n string extraction Apr 12, 2026
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.
@github-actions
Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_SCOPES was previously exported from the public lib/api barrel, but this change removes it and replaces it with buildApiTokenScopes. That’s a breaking API change for downstream consumers importing API_TOKEN_SCOPES. Consider keeping a backwards-compatible export (e.g. re-exporting API_TOKEN_SCOPES with 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.

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants