Skip to content

Shell - Product Localization - #4194

Merged
finnar-bin merged 196 commits into
devfrom
feat/4148-product-localization
Sep 15, 2026
Merged

finnar-bin merged 196 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.
@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 (document.title) stays hardcoded in English regardless of locale

Type: regression from this PR
Steps:

  1. Switch the active locale to any non-English locale (e.g. ru-RU) — the rest of the shell UI (sidebar, buttons, dialogs) localizes correctly.
  2. Navigate to /media. Observe document.title.
  3. Navigate to /content. Observe document.title.
    Expected: Title segments reflect the selected locale, consistent with the fully-localized on-screen UI.
    Actual: Title is "All Media - Медиа - Zesty.io - INTERNAL-NEGATIVE-QA - Manager" on /media and "Content - Контент - Zesty.io - INTERNAL-NEGATIVE-QA - Manager" on /content — the sub-app label ("Медиа"/"Контент", from the translated tab.name) is correctly Russian, but the leading segment ("All Media"/"Content") and the trailing "Manager" literal come from the hardcoded appNameMap and literal strings in setDocumentTitle() (src/shell/store/ui.ts lines ~281-293 and ~727-741), which is never run through t(). The same function also hardcodes "All Models" for /schema and Search for "..." for search tabs. This is inconsistent with every other piece of chrome in the same view, which this PR fully translates.
    Console/network: clean — no errors, purely a text-localization gap.
    Reproduced twice: once on /media, once independently on /content after a fresh locale-switch cycle.

🟡 Deleted content models never show the new "(Deleted)" fallback label in Activity Log → Resources — row renders permanently blank instead

Type: regression from this PR
Steps:

  1. Go to /reports/activity-log/resources (default en-US locale is enough to reproduce — no locale switch needed).
  2. Let the resource list finish loading, and look at the rows whose secondary line ends in • Content Model (the model was deleted after it accrued activity).
  3. Compare them to the rows for deleted content items, which render as "<ZUID> (Deleted)".
    Expected: ModelResourceListItem (src/apps/reports/src/app/views/ActivityLog/components/ResourceListItem/ModelResourceListItem.js) was updated by this PR to add a t("reports.deletedZUID", { zuid }) fallback for exactly this case, mirroring the working content-item behavior.
    Actual: The fallback never fires. fetchModel() (src/shell/store/models.js ~line 105) treats a 404 as "do nothing" (no dispatch, no rejection) rather than rejecting, so the component's .catch(() => setModelError(true)) never runs, modelError stays false, modelData stays undefined, and primary is passed undefined into MUI's ListItemText — which silently renders no primary text at all. The row is left permanently blank (only the secondary "Last action @ ... • Content Model" line shows), forever, instead of the intended "<ZUID> (Deleted)" text. Confirmed the two blank rows correspond to two GET /v1/content/models/<zuid> calls that return 404 in the network log. This reproduces identically in en-US, so it's not a translation-string bug, but the fallback path this PR added is dead code — the new key is unreachable given how fetchModel resolves.
    Console/network: two 404 responses per affected row: GET https://8-acabf6a8d6-bj9tr2.api.dev.zesty.io/v1/content/models/6-ba9382ea8f-w8x5zk and .../6-8ee58385ee-lc7th1. No uncaught exceptions.
    Blank rows for deleted content models next to correctly-labeled deleted content items
    Reproduced twice via full page reload of /reports/activity-log/resources; same two ZUIDs blank both times, same 404s both times.
Also checked and working correctly
  • Initial app load in en-US (default locale) — no i18n console errors on Launchpad
  • Pre-existing 404s on media-manager/metrics endpoints (unrelated to this PR)
  • Switching whole-app locale to ru-RU (Russian, a CLDR plural-heavy locale) — no missing-key crashes (dev throws on any missing translation key) on Content, Schema, or Media
  • Multi-select bulk Publish dialog in ru-RU with 4 items selected — correct Russian plural form ("4 элементах"), count interpolates correctly, no crash
  • Content item grid renders a cell containing raw <b>bold</b> & <script>x</script> as literal escaped text, not executed markup — no XSS via that field
  • Relative "updated" dates in Schema model list correctly localized to Russian (e.g. "почти 8 лет назад")
  • Deleted content-item rows in Activity Log → Resources correctly show "<ZUID> (Deleted)" — only the content-model variant of this fallback is broken

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

Comment thread src/apps/active-preview/i18n.js
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Code Review — ✅ No blockers · 🟡 1 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/leads/src/app/components/LeadExporter/FormGroupSelector/FormGroupSelector.js:70 — the Form Group filter's placeholder key leads.selectRelease resolves to "Select release...", a mismatched/pre-existing copy bug this PR preserves rather than introduces — worth a follow-up fix, not a new localization defect.
  • public/locales/nl-NL/activePreview.json:12"saving": "Opslaan..." adds an ellipsis not present in the English source ("Saving"); harmless stylistic drift, not a mistranslation.

@finnar-bin
finnar-bin merged commit 0c00876 into dev Sep 15, 2026
35 of 44 checks passed
@finnar-bin
finnar-bin deleted the feat/4148-product-localization branch September 15, 2026 02:25
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