Skip to content

Shell - Product Localization - #4194

Open
finnar-bin wants to merge 195 commits into
devfrom
feat/4148-product-localization
Open

Shell - Product Localization#4194
finnar-bin wants to merge 195 commits into
devfrom
feat/4148-product-localization

Conversation

@finnar-bin

@finnar-bin finnar-bin commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Resolves #4148

Summary

Rolls out full i18next + react-i18next localization across manager-ui — the app shell and every sub-app — supporting 6 locales: en-US (fallback), es-ES, hi-IN, zh-CN, ru-RU, nl-NL.

Dependencies

Out of scope for this PR: the language switcher UI and the DB write-back of the selected locale both live in the two dependencies above, not in manager-ui. This repo only consumes prefs.locale (src/shell/components/load-instance/index.js) and caches the resolved value to localStorage client-side — it never writes locale back to the database itself.

Architecture

  • Config lives in src/shell/i18n/index.ts, loaded before the React root renders; root is wrapped in <Suspense>.
  • Locale data is served from public/locales/<locale>/<namespace>.json.
  • DB preference wins eventually, not on first paint — the app renders right away using the cached/browser locale, then switches to the DB preference once it loads, instead of blocking render on that fetch. We chose a possible brief locale flash over a blank white screen while waiting.
  • Keys are flat, qualified camelCase strings (t("content.publishItem")) — the namespace is the first dot-segment, everything after is one flat key. No nested JSON, no second dot.
  • All 15 namespaces load eagerly at boot, not lazy-loaded per sub-app. Each sub-app root still wraps a local <Suspense> and calls useTranslation("<ns>") once, but that trigger is now effectively a no-op since the namespace is already resolved by init time — kept intentionally. True lazy-loading was reverted: it caused crashes from a race condition where Redux-driven code (thunks/middleware calling i18n.t() outside of React) could fire before a sub-app's namespace had loaded, and the perf gain was minimal anyway.
  • Dev throws on any missing key (with the en-US fallback disabled in dev so non-English gaps surface immediately); stage/prod fall back to en-US and report once per key to Sentry.
  • MUI component chrome (DataGrid/DatePicker/Autocomplete labels) localizes separately through localizeTheme / LocalizedThemeProvider, not through t().
  • Dates go through formatLocalized / formatDistanceToNowLocalized; machine formats (yyyy-MM-dd, API/CSV payloads, URL params) are intentionally left locale-independent.

Tooling added

  • Workflow({ name: "localize" }) — an AI-driven pipeline (Discovery → Extract & Wire → Composer → Verifier) for localizing new copy going forward. Extracts hardcoded strings, wires t()/i18n.t() calls, writes en-US + English-placeholder locale JSON, and verifies (tsc, JSON validity, key parity, broken-key refs). See README's "Localizing new copy" section.

  • npm run i18n:extract — a lightweight i18next-parser safety net that statically finds t() calls and flags/backfills any keys missing from locale JSON. Safe to run repeatedly.

  • .github/workflows/claude-localization-reviewer.yml ("Claude Localization Reviewer") — a dedicated PR check that runs only when a PR touches src/**/*.{js,jsx,ts,tsx} or public/locales/**. Two layers:

    • ci/scripts/check_localization_objective.js — deterministic checks: TypeScript errors (scoped to changed files), locale JSON validity, cross-locale key parity (CLDR-plural-aware per locale), and broken t()/i18n.t() key references.
    • A Claude review pass over the changed diff for what only language/intent can catch: missed t() wiring for new hardcoded copy, value-formatting rule violations, and translation quality/grammar in the non-English locale files.

    Posts inline PR comments on each confirmed finding (ci/scripts/post_inline_comments.js, generic/reusable — parses a report's Blocking bullets rather than relying on the model to call its own commenting tool) plus a summary comment, and fails the check on any confirmed finding. The summary comment is posted fresh on every run rather than updated in place, mirroring claude-auto-reviewer.yml, so the PR timeline shows the review history ("FAIL" → fix commits → "PASS"). ci/scripts/build_localization_diff.js keeps the diff handed to Claude within a fixed byte budget on large PRs without starving source-file coverage in favor of locale-file coverage (or vice versa). Runs on claude-sonnet-5.

  • cypress/e2e/.../sub-app-translations.spec.js — Cypress coverage for locale switching across sub-apps.

Screenshots / video

Screencast_20260708_094140.webm

finnar-bin added 30 commits June 8, 2026 11:25
Install react-i18next and supporting plugins, configure i18next with chained localStorage/HTTP backend, lazy loading, and git-hash-based cache busting. Add locale file structure for all 6 supported languages and i18next-parser config for key extraction.
…e (Phase 2)

Connect LocaleSwitcher to i18n.changeLanguage(), add updateUser RTK Query mutation to persist locale in user.prefs, and apply DB locale preference on boot via load-instance before first render.
…ration (Phase 3 partial)

- Replace hardcoded common strings (Save, Cancel, Edit, etc.) with t() calls across ~73 files
- Fix i18next-parser config defaultValue to use function form so locale files get actual English strings
- Populate public/locales/*/common.json with correct English values as translation placeholders
- Upgrade TypeScript 4.9 → 5.9 to support i18next v26 type definitions (uses const type params)
- Add skipLibCheck to tsconfig to suppress remaining third-party type incompatibilities
- Add "shell" to i18n ns array in preparation for Phase 3 shell namespace
…ate plan

- Strip temporary { defaultValue: "..." } from all common namespace t() calls
- Update LOCALIZATION_PLAN.md: mark common namespace complete, check off Phase 5 items already done, reflect shell.json placeholder files added
…space

- Translate all 9 Tier 1 shell components (ResizeableContainer, InvalidUrl,
  UserFilter, GlobalDirtyCodeModal, ConfirmDeleteModal, DropdownMenu,
  Comment/index, Favicon, GlobalDomainsMenu)
- Move comment, reply, prod keys from shell to common namespace
- Fix module-level CHIP_TITLE constant in GlobalDomainsMenu — replaced with
  inline t() ternary since module scope can't call t()
- Add namespace prefix to existing t() calls across shell and sub-app files
- Update locale files for all 6 languages (common + shell namespaces)
- Update implementation plan with conventions for functions, object maps,
  prop-passed strings, and cross-namespace deduplication rules
…ate plan

Locale files are confirmed correct — temporary defaultValue scaffolding removed
from all 9 Tier 1 shell components. Updated plan to reflect completed status.
…mit link

- Replace slug-to-name conversion in global-menu with a productLabels map
  backed by t() — all 13 product names now translated across 6 locales
- Add navAppTooltip key with {{name}} interpolation for the product tooltips
- Translate GlobalSidebar "View source code commit" link title
- data-cy values preserved via separate dataCyName variable so tests are unaffected
- Update plan to reflect completed items
Resolve the UI locale authoritatively from the logged-in user and fall
back to the default. Previously a user with no saved locale (or a
different one) kept the prior session's language because localStorage
and i18n still held it after logout and there was no fallback.
Localize AccessDenied, NoInstancePermission, ConfirmPublishModal,
global-tabs Dropdown (pluralized results), InstancesList (decoupled
header text from icon logic), GlobalDocsMenu (module-level doc arrays
+ width bump to 520), InviteMembersModal, DateFilter, and RoleAccessInfo
(role names left untranslated). Adds and translates all new common/shell
keys across the 6 locales and marks Tier 2 done in the plan.
Comment thread src/shell/i18n/index.ts Outdated
Comment thread src/shell/views/Shell/AIDrawer.tsx Outdated
Comment thread src/shell/components/withAi/AIGenerator.tsx Outdated
Comment thread src/shell/services/accounts.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers · 🟡 4 warning(s) — see inline comments

- resolveDevLng() now runs the stored app_locale through toSupportedLocale()
  before use, matching the navigator.language branch -- a stale/unsupported
  cached value no longer crashes dev on the first t() call.
- Type the Autocomplete getOptionLabel callbacks against TONE_OPTIONS'
  element type instead of any (AIDrawer.tsx, AIGenerator.tsx x2).
- updateUser's onQueryStarted forwards failed PUT errors to Sentry instead of
  silently swallowing them.
@github-actions

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 3 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • src/apps/home/app/components/Header.tsx:87 — "Instance summary of the" and "last {{count}} days" are two separately-translated fragments concatenated into one sentence; word order assumptions may not hold in all 6 locales.
  • public/locales/{zh-CN,ru-RU}/activePreview.json and public/locales/{zh-CN,ru-RU}/blocks.json — GitHub couldn't diff these (file too large), so translation quality for those two locales in this PR's changed namespaces could not be reviewed.
  • Diff truncation means locale JSON diffs beyond blocks.json (code, content, shell, dashboard, leads, marketplace, media, release, reports, schema, seo, settings) were not visible, so translation-quality review only covers activePreview and blocks.

Comment thread ci/scripts/check_localization_objective.js
Comment thread ci/scripts/check_localization_objective.js
@github-actions

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers · 🟡 2 warning(s) — see inline comments

@github-actions

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 2 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • src/apps/seo/src/app/components/RedirectsDialogProvider/CreateRedirects/CreateForm.tsxt(FORM_LABELS[actionType]?.header/subHeader/incomingPath), t(option.label), and getToolTips(t).code/targetType all assume ../constants.ts now stores i18n keys instead of literal strings, but that file's diff wasn't included (likely truncated) — verify it was actually converted.
  • src/apps/seo/src/app/components/RedirectsDialogProvider/CreateRedirects/SearchField.tsx:311{t(TARGET_ERRORS.unpublished)} has the same unverifiable dependency on ../constants.ts being converted to i18n keys.
  • public/locales/zh-CN/activePreview.json, public/locales/zh-CN/blocks.json, public/locales/ru-RU/activePreview.json, public/locales/ru-RU/blocks.json — no patch available (file too large to diff), so translation quality for these locales/namespaces couldn't be checked.

@github-actions

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers

@agalin920

Copy link
Copy Markdown
Contributor

@finnar-bin please run negative QA on this

@github-actions

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 2 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • src/apps/code-editor/src/app/components/BottomDrawer/FileStatus.tsx — several labels (code.branch, code.modelZuid, code.webEngineLink, code.fileZuid, code.fileType, code.lastEdited) render with a hardcoded ": "/":" separator outside t(), so locales can't adjust that punctuation; consider folding the colon into the translation string.
  • Helper files referenced by this diff but not included in the excerpt (e.g. src/apps/seo/src/app/components/RedirectsDialogProvider/constants for getToolTips/FORM_LABELS, and src/apps/settings/src/app/utils/categoryLabels for getCategoryLabel/getStyleCategoryLabel) weren't reviewable — confirm they return i18n keys (not literal English) since call sites now pass their values through t().

@github-actions

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 5 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • public/locales/nl-NL/activePreview.json:12 — "saving" adds a trailing ellipsis not present in the en-US source ("Saving").
  • public/locales/hi-IN/activePreview.json:2 — Hindi translation embeds an English gloss "(preview domain)" in parentheses.
  • public/locales/hi-IN/activePreview.json:13 — Hindi translation embeds an English gloss "(session)" in parentheses.
  • public/locales/es-ES/blocks.json:13 — "howToUseBlocks" capitalizes "Bloques" inconsistently with sibling key "howToCreateBlock" using lowercase "bloque".
  • src/apps/seo/src/app/components/RedirectsDialogProvider/CreateRedirects/CreateForm.tsx:434 — can't verify getToolTips/FORM_LABELS/HTTP_CODE_OPTIONS/TARGET_OPTIONS in ../constants comply with i18n rules since that file isn't in the diff.

Comment thread src/apps/active-preview/Preview.js
Comment thread etc/nginx.conf
Comment thread src/shell/components/load-instance/index.js
@github-actions

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers · 🟡 3 warning(s) — see inline comments

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Negative QA

🟡 Browser tab title shows the app name twice — once in English, once translated — for any non-English locale

Type: regression from this PR
Steps:

  1. In a browser console on any page (e.g. http://8-acabf6a8d6-bj9tr2.manager.dev.zesty.io:8080/launchpad), run localStorage.setItem('app_locale', 'hi-IN') then reload.
  2. Read document.title.
  3. Repeat on a hard reload — reproduces every time.

Expected: Tab title shows the sub-app name once (e.g. Launchpad - Zesty.io - <instance> - Manager), same dedup behavior as English.
Actual: Title is Launchpad - लॉन्चपैड - Zesty.io - Manager — the English name and the translated name both appear. Root cause: in src/shell/store/ui.ts createTab(), tab.app is always assigned from the hardcoded English appNameMap ({ launchpad: "Launchpad", content: "Content", media: "All Media", ... }), while tab.name for the same route is assigned from the translated i18n.t(APP_DISPLAY_KEY[name]). setDocumentTitle() in the same file suppresses the sub-app name only if (app === item) — since one is English and the other is translated, they never match once the active locale is non-English, so both are rendered instead of being deduped. This affects every route driven by appNameMap/APP_DISPLAY_KEY (launchpad, content, blocks, media, schema, code, leads, settings, apps, redirects, search), not just Launchpad, and will persist for the full session of any user whose resolved locale is not English (not just a transient race during load).
Console/network: clean — no JS errors, purely a text/logic bug.

Also checked and working correctly
  • Confirmed the app is running in development config (fallbackLng: false, missing keys throw in dev) — attempted to trigger a missing-translation crash by forcing hi-IN/other locales, but src/shell/components/load-instance/index.js re-resolves and overwrites the active locale from the account's prefs.locale (falling back to navigator.language, which is en-US in this headless session) shortly after every load, so the app always converges back to English before a sustained non-English render could be observed — no way to force a persistent non-English session without an account-level locale preference, so this path could not be fully exercised.
  • Sidebar navigation labels, Home dashboard content ("Good Morning, Developers", metric cards, resources panel) render correctly in English at steady state; no broken layout or overflow observed.
  • No console errors or failed network requests attributable to i18n resource loading (/locales/{{lng}}/{{ns}}.json) were observed; the only console errors seen were pre-existing, unrelated 404s from media-manager and metrics endpoints on this fixture instance.
  • Content editor field validation (new getFieldErrorMessages.ts): typing 200 characters into a 150-max-length Text field correctly showed the translated, correctly-pluralized message "Exceeding by 50 characters." with no raw key or interpolation artifact, and triggered the "Resolve invalid field values" dialog as expected.
  • Navigated /blocks and /apps (both touched by this PR's SubAppSkeleton addition) — pages loaded cleanly with no console errors and no stuck/mismatched loading skeletons.
  • Tried markup/script-looking strings (<b>bold</b> & <script>x</script>) already seeded in a qa_negative_* content item's Text field — rendered as literal escaped text in the editor and in the live preview iframe, no script execution.

@github-actions

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 4 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • src/apps/studio/components/StudioInspectorPanel.tsx{t(meta.description)} wraps getFieldMeta(...).description; if that description isn't itself an i18n key (source not shown in diff), this silently renders untranslated/raw text instead of failing loudly.
  • public/locales/nl-NL/activePreview.json:12"saving": "Opslaan..." adds an ellipsis not present in the English source ("Saving"); debatable whether this counts as a decorative-character violation or an intentional UX convention.
  • public/locales/hi-IN/activePreview.jsondisconnectedFromPreviewDomain and sessionNotActive append redundant English glosses in parens (e.g. "पूर्वावलोकन डोमेन (preview domain)"), inconsistent with other entries in the same file that don't gloss terms (e.g. meta, web).
  • public/locales/es-ES/blocks.jsonhowToUseBlocks: "Cómo usar los Bloques" capitalizes "Bloques" mid-sentence, inconsistent with standard Spanish title casing used elsewhere in the same file.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Localization Reviewer — ✅ No blockers · 🟡 4 advisory note(s)

⚠️ This PR's diff was too large to review in full — findings below are non-exhaustive.

🔴 Blocking

None

🟡 Advisory

  • src/apps/studio/components/StudioInspectorPanel.tsx:401{t(meta.description)} treats getFieldMeta().description as an i18n key; confirm that helper (not in this diff) actually returns translation keys and not literal English text.
  • public/locales/nl-NL/activePreview.json:12"saving": "Opslaan..." adds a trailing ellipsis not present in the English "Saving", an inconsistent addition.
  • public/locales/hi-IN/activePreview.json — several values embed English glosses in parentheses (e.g. "...डोमेन (preview domain) से...", "...सत्र (session)..."); confirm this bilingual-gloss style is intentional rather than leftover English.
  • public/locales/es-ES/blocks.json:14"howToUseBlocks": "Cómo usar los Bloques" capitalizes "Bloques" inconsistently with the lowercase "bloques" used elsewhere in the same file.

}

i18n.use(initReactI18next).init({
lng: "en-US",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 activePreview translations are dead code — locale always en-US
active-preview/i18n.js hardcodes lng: "en-US" with no LanguageDetector, so the non-English strings in every public/locales/*/activePreview.json are never loaded or displayed. The active-preview runs on the same origin as the shell, so localStorage.getItem("app_locale") is accessible — add LanguageDetector with the same detection config the shell uses, or at minimum seed lng from localStorage.getItem("app_locale") ?? "en-US".

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers · 🟡 1 warning(s) — see inline comments

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

Labels

feature Additional functionality that should be added to Zesty

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shell - Product Localization

2 participants