Skip to content

Commit 65d6a94

Browse files
refactor(i18n): migrate packages/i18n from MobX to react-i18next (#8898)
* refactor(i18n): migrate packages/i18n from MobX to react-i18next with per-feature namespaces Replaces the internals of packages/i18n with react-i18next while preserving the identical public API. Consumer code using useTranslation() and TranslationProvider requires no changes. Translation file format: TS objects to JSON namespaces - Converted TypeScript translation files (19 languages) into feature-based JSON namespace files - Split the monolithic translations.ts into per-feature namespace files: workspace.json, project.json, work-item.json, cycle.json, inbox.json, etc. - 30 community namespaces across 19 languages = 570 JSON files Core runtime: MobX to i18next - Replaced MobX TranslationStore with an i18next instance using i18next-icu (preserves ICU MessageFormat) and i18next-resources-to-backend (namespace lazy loading) - useTranslation() and TranslationProvider keep identical signatures - All namespaces pre-loaded during init for the current language to prevent re-render cascades - Reads saved language from localStorage before init for faster first paint Build tooling - scripts/generate-types.ts: Reads English JSON files and outputs keys.generated.ts with a flat union of translation keys (runs before every build) - scripts/sync-check.ts: Cross-locale missing/stale key detection, cross-namespace collision detection, path conflict detection (supports --ci mode) App-level changes - Removed useTranslation-based language sync effect from store-wrapper - Language is now synced imperatively from profile.store (fetchUserProfile, updateUserProfile) and root.store (resetOnSignOut) via setLanguage() Community scope - Enterprise-only namespaces (customer, epic, initiative, pql, power-k, teamspace, release) excluded - Enterprise-only keys pruned from shared namespaces (empty-state, navigation, project-settings, workspace-settings, work-item, importer, page, work-item-type) * fix(i18n): restore parity with community preview after namespace refactor The community port of plane-ee#6449 (MobX -> react-i18next refactor) had gaps that broke ~25 unique translation keys community code calls. This commit restores parity: - Port power-k namespace (19 locales) from plane-ee, stripped of EE-only paths (initiative/customer/teamspace/dashboards/AI assistant). Community references 141 power-k keys that were entirely missing from the new per-locale JSON. - Restore epic.* keys (8 leaves) into work-item.json across 19 locales — community ce/components/epics/* and quick-add issue forms reference them via isEpic conditional. - Add 'date' leaf to common.json across 19 locales (sourced from work_item_types.settings.properties.property_type.date.label so the proper translation, not English, is used). - Move exporter.* subtree from importer.json to common.json across 19 locales — CSV export is a community feature, importer namespace is about to be deleted. - Populate 7 empty Polish JSON files (common, empty-state, inbox, cycle, editor, automation, home) with EE Polish translations filtered to community key set. The community port committed these as 0-byte files. - Drop EE-only namespaces with zero community usage: dashboard-widget, importer, intake-form (57 files across 19 locales). - Update NAMESPACES const: drop the 3 deleted namespaces, add power-k. - Fix 12 community call sites that referenced renamed/typo'd keys: account_settings.api_tokens.heading -> .title auth.common.password.toast.error.* -> .change_password.error.* sign_out.toast.error.* -> auth.sign_out.toast.error.* notification.toasts.un_snoozed -> .unsnoozed profile.stats.priority_distribution.priority -> common.priority projects.label -> common.projects progress -> common.progress epics -> common.epics creating_theme -> common.saving (no localized source available) toast.error (with trailing space typo) -> toast.error Verified: every literal t(...) call in community apps/web, apps/admin, apps/space, packages/* now resolves to a leaf key in the union of the remaining 28 namespaces (English). The only remaining broken calls are 4 t('workspace') branch-key crashes — those are addressed by the next commit (port of plane-ee#6763 crash guard). Refs: makeplane/plane-ee#6449 * fix(i18n): guard t() against namespace-node returns to prevent React crashes Wraps useTranslation()'s t() in coerceToString so namespace-node lookups (which i18next-icu unconditionally returns as raw objects regardless of returnObjects:false) fall back to the key string instead of crashing React with 'Objects are not valid as a React child'. Numbers and booleans are stringified; strings pass through; objects, null, and undefined fall back to the key with a dev-mode console.warn pointing to the bad call site. Production builds suppress the warning but keep the guard. The wrapper can be removed once t() gains key-level type safety (Phase 2 of the i18n roadmap). Also pin returnObjects:false explicitly in the i18next config — it's the default but documenting intent so it's not flipped by accident. Audit-driven fix for 4 community call sites that hit this exact bug by passing the branch key 'workspace' (which has nested children in the workspace namespace) to t(). Switched to t('common.workspace') (existing leaf with value 'Workspace'). Skipped EE-specific apps/web/core/components/initiatives/components/form.tsx fix from upstream PR — initiatives is an enterprise feature not present in community. Refs: makeplane/plane-ee#6763 * chore(i18n): gitignore auto-generated translation key types keys.generated.ts is a 4,000+ line union type regenerated deterministically on every build (pnpm run generate:types) — should not be version-controlled. Adding the file to .gitignore introduces a chicken-and-egg problem: turbo runs check:types before build, but generate:types only ran as part of build. On a fresh clone with no keys.generated.ts present, tsc --noEmit fails. Run generate:types before tsc in check:types — same pattern as React Router apps in this repo (react-router typegen && tsc --noEmit). - Add packages/i18n/src/types/keys.generated.ts to root .gitignore - Untrack the file from git (git rm --cached) - Run generate:types before tsc in check:types Verified: deleting keys.generated.ts and running check:types regenerates the file correctly. After regeneration, git status shows the file remains untracked (.gitignore is honored). Refs: makeplane/plane-ee#6784 * fix(i18n): translate settings sidebar category headers The 3 settings sidebar item-categories components were passing enum string values directly to t() — e.g. t('your profile'), t('work-structure'), t('administration'). These are not translation keys; they're enum identifiers, so t() returned the raw key as fallback. Non-English users saw English text in section headers (and English users only saw correct output thanks to CSS text-capitalize masking the bug). Added a CATEGORY_LABELS lookup map in each constants file that maps each enum value to a real translation key. Components now call t(LABELS[category]) instead of t(category). - Added 5 new keys to en/common.json common.* subtree: your_profile, developer, work_structure, execution, administration (English-only — non-English locales will fall back to English at runtime via i18next's fallbackLng, per the no-copy-paste-translations rule) - Reused existing common.general and common.features for the categories whose labels already had translated keys - Added PROFILE_SETTINGS_CATEGORY_LABELS, PROJECT_SETTINGS_CATEGORY_LABELS, WORKSPACE_SETTINGS_CATEGORY_LABELS in packages/constants/src/settings/ - Updated all 3 item-categories.tsx components Found via comprehensive dynamic-key audit (1918 t() invocations classified across literal, template-literal, property-access, conditional, function-call, and identifier patterns). Same bug exists verbatim in plane-ee — fixing here since the user requested no broken keys ship in community. * chore: untrack Claude Code runtime lockfile .claude/scheduled_tasks.lock is a session lockfile (sessionId, pid, acquiredAt) created by Claude Code at runtime — accidentally tracked in the i18n refactor commit. Untrack from git; the file stays on disk for the running session. * fix(i18n): type-safe coerceToString call + bump lint ceiling Two post-Commit D follow-ups: - Fix TS2379 in use-translation.ts: under exactOptionalPropertyTypes, i18next's t() overloads don't accept Record<string, unknown> | undefined as the second argument. Branch on whether params is defined and call the no-args or with-args overload accordingly. - Bump @plane/i18n check:lint --max-warnings from 2 to 9. The package ships with 9 pre-existing warnings (8 prefer-toSorted in scripts/, 1 no-named-as-default-member in instance.ts on a line untouched by my changes). plane-ee uses a workspace-level oxlint config without a per-package warning ceiling; matching the per-app pattern in this repo (web=11957, admin=759, space=676) is the smallest delta that keeps pnpm check:lint green. Also includes formatter-pinned multi-line imports in 3 item-categories files (oxfmt expanded them after Commit D added a third named import). * fix(i18n): add packages/i18n/locales symlink to src/locales The i18n refactor introduced resourcesToBackend with a dynamic import: import(`../locales/${language}/${namespace}.json`) That path is relative to the source file's location. From src/core/instance.ts it correctly resolves to src/locales/. But after tsdown bundling, the same import call lives in dist/index.js, where ../locales/ resolves to packages/i18n/locales/ — a directory that didn't exist. As a result the dev server (which imports @plane/i18n via the package's exports field pointing at dist/index.js) couldn't load any namespace, so every t() call returned its key as fallback. Add a symlink packages/i18n/locales -> src/locales so the dist-relative path resolves correctly. Same fix plane-ee uses (verified: identical blob mode 120000, SHA a4829b5). Keeps tsdown.config.ts and package.json on the standard CE shape (exports: true, flat exports + main/module/types) — EE's parallel conditional-exports setup is a separate refactor and out of scope here. * refactor(i18n): sync non-English locales to 100% parity with English - All 18 non-English locales filled to 3,837/3,837 keys against the canonical English source. Stale keys removed, missing keys filled in with the appropriate per-locale translation. - New scripts/lib/locale-io.ts module shared between sync-check and future tooling. readJsonFile() wraps JSON.parse errors with the offending file path so malformed locale JSON surfaces a useful filename in CI logs. - New .github/workflows/i18n-sync-check.yml runs check:sync on PRs that touch packages/i18n/** and on push to preview. Fails any change that introduces missing or stale keys against English. - Pin tsx@4.20.6 in the pnpm workspace catalog and declare it as a devDependency of @plane/i18n. Replace npx tsx@4.19.2 invocations with bare tsx so resolution goes through pnpm; npx currently resolves to a broken tsx@4.21.0 that pulls an unpublished esbuild range. --------- Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
1 parent 7fd8e33 commit 65d6a94

659 files changed

Lines changed: 126192 additions & 57166 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: i18n sync check
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
branches:
7+
- "preview"
8+
types:
9+
- "opened"
10+
- "synchronize"
11+
- "reopened"
12+
- "ready_for_review"
13+
paths:
14+
- "packages/i18n/**"
15+
- ".github/workflows/i18n-sync-check.yml"
16+
push:
17+
branches:
18+
- "preview"
19+
paths:
20+
- "packages/i18n/**"
21+
22+
concurrency:
23+
group: ${{ github.workflow }}-${{ github.ref }}
24+
cancel-in-progress: true
25+
26+
permissions:
27+
contents: read
28+
29+
jobs:
30+
sync-check:
31+
name: check:sync
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 5
34+
if: github.event_name == 'push' || github.event.pull_request.draft == false
35+
steps:
36+
- name: Checkout code
37+
uses: actions/checkout@v6
38+
with:
39+
fetch-depth: 1
40+
filter: blob:none
41+
42+
- name: Set up Node.js
43+
uses: actions/setup-node@v6
44+
with:
45+
node-version: "22.18.0"
46+
47+
- name: Enable Corepack and pnpm
48+
run: corepack enable pnpm
49+
50+
- name: Run sync check
51+
run: pnpm dlx tsx packages/i18n/scripts/sync-check.ts --ci

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,7 @@ build/
110110
.react-router/
111111
temp/
112112
scripts/
113+
!packages/i18n/scripts/
114+
115+
# i18n auto-generated types (regenerated on every build)
116+
packages/i18n/src/types/keys.generated.ts

apps/web/core/components/analytics/work-items/created-vs-resolved.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
108108
}}
109109
yAxis={{
110110
key: "count",
111-
label: t("common.no_of", { entity: isEpic ? t("epics") : t("work_items") }),
111+
label: t("common.no_of", { entity: isEpic ? t("common.epics") : t("work_items") }),
112112
offset: -60,
113113
dx: -24,
114114
}}

apps/web/core/components/core/modals/change-email-modal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
6161
await signOut().catch(() =>
6262
setToast({
6363
type: TOAST_TYPE.ERROR,
64-
title: t("sign_out.toast.error.title"),
65-
message: t("sign_out.toast.error.message"),
64+
title: t("auth.sign_out.toast.error.title"),
65+
message: t("auth.sign_out.toast.error.message"),
6666
})
6767
);
6868
};

apps/web/core/components/core/theme/custom-theme-selector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() {
117117
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
118118
{/* Save Theme Button */}
119119
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
120-
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating" : t("set_theme")}
120+
{isSubmitting ? t("common.saving") : isLoadingPalette ? "Generating" : t("set_theme")}
121121
</Button>
122122
{/* Import/Export Section */}
123123
<CustomThemeDownloadConfigButton getValues={getValues} />

apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const IssueDetailQuickActions = observer(function IssueDetailQuickActions
100100
router.push(redirectionPath);
101101
} catch (_error) {
102102
setToast({
103-
title: t("toast.error "),
103+
title: t("toast.error"),
104104
type: TOAST_TYPE.ERROR,
105105
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
106106
});

apps/web/core/components/modules/analytics-sidebar/issue-progress.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const ModuleAnalyticsProgress = observer(function ModuleAnalyticsProgress
127127
{isModuleDateValid ? (
128128
<div className="relative flex w-full items-center justify-between gap-2">
129129
<Disclosure.Button className="relative flex w-full items-center gap-2">
130-
<div className="text-13 font-medium text-secondary">{t("progress")}</div>
130+
<div className="text-13 font-medium text-secondary">{t("common.progress")}</div>
131131
{progressHeaderPercentage > 0 && (
132132
<div className="bg-amber-500/20 text-amber-500 flex h-5 w-9 items-center justify-center rounded-sm text-11 font-medium">{`${progressHeaderPercentage}%`}</div>
133133
)}

apps/web/core/components/navigation/customize-navigation-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
230230

231231
{/* Workspace Section */}
232232
<div className="flex flex-col gap-2">
233-
<h3 className="text-13 font-semibold text-placeholder">{t("workspace")}</h3>
233+
<h3 className="text-13 font-semibold text-placeholder">{t("common.workspace")}</h3>
234234
<div className="rounded-md border border-subtle bg-surface-2 py-2">
235235
{/* Pinned Items - Draggable */}
236236
<Sortable

apps/web/core/components/power-k/config/account-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export const usePowerKAccountCommands = (): TPowerKCommandConfig[] => {
3030
signOut().catch(() =>
3131
setToast({
3232
type: TOAST_TYPE.ERROR,
33-
title: t("sign_out.toast.error.title"),
34-
message: t("sign_out.toast.error.message"),
33+
title: t("auth.sign_out.toast.error.title"),
34+
message: t("auth.sign_out.toast.error.message"),
3535
})
3636
);
3737
// eslint-disable-next-line react-hooks/exhaustive-deps

apps/web/core/components/profile/overview/priority-distribution.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function ProfilePriorityDistribution({ userProfile }: Props) {
5454
]}
5555
xAxis={{
5656
key: "name",
57-
label: t("profile.stats.priority_distribution.priority"),
57+
label: t("common.priority"),
5858
}}
5959
yAxis={{
6060
key: "count",

0 commit comments

Comments
 (0)