Skip to content

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

Merged
ascorbic merged 49 commits intoemdash-cms:mainfrom
ophirbucai:i18n/module-level-extractions
Apr 12, 2026
Merged

feat(admin): add module-level Lingui i18n string extraction#470
ascorbic merged 49 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?

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 t macro directly because it requires an active i18n instance from useLingui(), which is only available inside React components.

Solution: Use Lingui's msg macro for lazy message descriptors:

  1. Module-level: Define strings with msg`placeholder` (returns a MessageDescriptor)
  2. Component-level: Unwrap with t(descriptor) from useLingui() at render time

This defers translation until the component renders, when i18n is guaranteed to be initialized.

Files with module-level extractions:

  • AdminCommandPalette: Navigation items
  • PortableTextEditor: Block type definitions, embed config
  • BlockMenu, Widgets: Block/widget labels and descriptions
  • ContentTypeEditor: Field type labels and descriptions
  • ApiTokenSettings: Expiry options
  • roleDefinitions.ts: User role names and descriptions
  • WelcomeModal: Welcome cards
  • Additionally added an API token scopes contract test ensuring server and client enums don't drift 🪄

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

Screenshots / test output

Admin browser tests: 725/725 passing

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: cd03c0a

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: cd03c0a

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.
@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?

ophirbucai and others added 22 commits April 12, 2026 11:29
…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.
@ophirbucai ophirbucai requested a review from ascorbic April 12, 2026 16:56
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.

Great! Thanks

@ascorbic ascorbic merged commit 3914ae8 into emdash-cms:main Apr 12, 2026
27 of 28 checks passed
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