--- name: experience-ui-bundle-localize description: "MUST activate to localize / internationalize a uiBundles/*/src/ React project: extract hardcoded user-facing strings into Custom Labels, wire i18next over the Platform SDK GraphQL backend, add a language to a localized bundle, or troubleshoot label rendering across locales. Triggers: user-facing string literals in .tsx/.jsx, a CustomLabels.labels-meta.xml, a src/i18n/ directory or label-manifest.ts, t(…) calls, or requests to 'translate / localize / internationalize / support another language.' Scope: authenticated UI Bundles only (B2E CustomApplication-bound, or in-core accessCheck-bound apps). DO NOT TRIGGER when: the bundle is a site (B2C/B2B) app bound to a DigitalExperienceConfig (site localization not yet supported), building app shell/UI or styling, reading/writing/refreshing records (use experience-ui-bundle-salesforce-data-access), generating a new bundle (use experience-ui-bundle-frontend-generate), deploying (use experience-ui-bundle-deploy), or authoring translations in Translation Workbench." metadata: version: "1.0" minApiVersion: "68.0" relatedSkills: - "experience-ui-bundle-deploy" - "experience-ui-bundle-frontend-generate" - "experience-ui-bundle-salesforce-data-access" cliTools: - tool: ["jq"] semver: ">=1.6" - tool: ["npm"] semver: ">=7.0.0" - tool: ["sf"] semver: ">=2.0.0" --- # Localize a React UI Bundle Walk a developer through localizing a React UI Bundle: detect hardcoded user-facing strings, extract them into Salesforce Custom Labels, wire up i18next over the Platform SDK GraphQL backend, and verify labels render across locales. This file is the **workflow + guardrail spine**. Depth lives in linked docs: - **[references/i18n-setup.md](references/i18n-setup.md)**: the two files you write: the i18next init and the label manifest - **[references/label-xml.md](references/label-xml.md)**: Custom Labels and translation metadata XML shapes; the `namespace:Key` rules - **[references/interpolation.md](references/interpolation.md)**: positional `{0}/{1}` placeholder interpolation in labels - **[references/verifying.md](references/verifying.md)**: serve URL, locale flip, and verifying labels render - **[references/gotchas.md](references/gotchas.md)**: the three silent-fail traps: unregistered manifest keys, API-version bake-in, stale label cache ## The one-paragraph mental model A React UI Bundle can't use `@salesforce/label/*` the way LWC does, those imports resolve at compile time inside the platform's compiler, which your standalone React bundle doesn't go through. Instead, your app **fetches labels at runtime** through the Salesforce GraphQL UI API and hands them to **i18next** (a standard React i18n library) to render. The Platform SDK provides the runtime plumbing for this, a detector that reads the user's language, a backend that fetches labels over GraphQL, and a context fetch. You write two thin files: a short init that wires the SDK pieces into i18next, and a manifest listing which labels your app uses. The rest is authoring the labels themselves as Salesforce Custom Labels metadata. ```typescript import { useTranslation } from "react-i18next"; function WelcomeBanner() { const { t } = useTranslation("c"); // "c" = custom label namespace return

{t("Welcome_Text")}

; // renders "Welcome" or "Bienvenido" per user's language } ``` --- ## Step 0: Route the task | The task is… | Go to | |---|---| | Bundle doesn't exist yet | **experience-ui-bundle-frontend-generate** skill | | Deploying the app with its labels | **experience-ui-bundle-deploy** skill | | Localizing an existing bundle | **Workflow below** | --- ## Preconditions: verify before editing | # | Requirement | Verify | If missing | |---|---|---|---| | 1 | It's a `uiBundles/*/src/` React project | Project structure matches | Not a UI Bundle → route to the correct skill | | 2 | `@salesforce/platform-sdk` installed (≥11.42.1) | `package.json` in the UI bundle dir | Tell user to install it; cannot proceed | | 3 | You can identify where the app mounts | Read the entry file (usually `src/index.tsx`) | No clear mount point → ask user to point it out | | 4 | Target org actually supports API v68.0+ (runtime label GraphQL for UI Bundles ships in Release 264) | Run the runtime org-release check below | Org's max API version is below v68.0 (Release 262 or older) → cannot proceed; retarget a Release 264+ org or upgrade the org | | 5 | The bundle is an authenticated app (B2E, or an in-core internal app), not a public site | Run the authenticated-app detection below | Bundle is a site (B2C/B2B) app → localization is **not yet supported for site bundles**; stop and tell the user B2C support is planned for when B2C localization is ready | **Runtime org-release check (precondition 4).** The `platform.labels` GraphQL path that resolves labels at runtime for UI Bundles ships in Salesforce Release 264 (API v68.0 or higher). A `sourceApiVersion` in `sfdx-project.json` records what you declared, not what the org supports, so a newer CLI pointed at an older org can pass a static file check and then fail at runtime. Query the org's actual maximum API version before wiring anything: ```bash bash /scripts/check-org-api-version.sh ``` Exit `0` → the org supports v68.0+, proceed. Exit `1` → the org is too old or unreachable; do not write i18n wiring or labels, report the version mismatch to the user and stop. (`sf api request rest` inside the script keeps authentication at the CLI transport layer, so no access token enters context.) **Authenticated-app detection (precondition 5).** The bundle's type decides whether localization is supported, and it's decided by deterministic file and string checks. Pass the full path to the bundle dir; the script derives the metadata root from it, so the current directory does not matter: ```bash bash /scripts/detect-bundle-type.sh / dir> ``` Act on the exit code: `0` → authenticated app (in-core internal or B2E), proceed; `1` → site (B2C/B2B), localization is **not yet supported for site bundles**, stop and tell the user (B2C support is planned for when B2C localization is ready); `2` → unbound or cannot auto-detect, **ask the user to confirm** the bundle is an authenticated app (B2E or in-core internal) and stop if they cannot. If a precondition isn't met, stop: report the specific block to the user and record a plan item to return once it's resolved. Do not edit the bundle, in particular, never add i18n wiring or TODO markers to a site (B2C/B2B) bundle that precondition 5 gated off as unsupported. --- ## Workflow: the five steps Each step has a **checkable completion criterion** and a **confirm-before-continue** pause. ### Step 1: Detect **Goal:** Scan `.tsx`/`.jsx` files for user-facing hardcoded strings. **What to scan:** - String literals inside JSX tags: `

Welcome

` → candidate - String props shown to users: `placeholder="Enter name"` → candidate - User-facing accessible text: `aria-label`, `aria-describedby`, `alt` → candidate (a screen-reader user hears these, so they must localize too) **What to skip:** - Import statements - Object keys / property names - `data-*` attributes (machine-readable) - Test IDs (`data-testid`, `id` attributes) - Text already wrapped in `t()` calls - Console logs, error messages thrown to developers (not user-facing) - Class names, file paths, technical constants **Action:** 1. Scan the `src/` directory for `.tsx` and `.jsx` files 2. Extract candidates, showing file path + line number for each 3. Show the list to the developer **Completion criterion:** Developer confirms the list (or edits it to remove false positives). **Pause:** "I found N user-facing strings across M components. Here's the list: [show file:line + string]. Look right? [confirm / edit the list / skip some]" --- ### Step 2: Extract **Goal:** For each confirmed string, add a Custom Label and replace the JSX literal with a `t()` call. **Action for each string:** 1. **Propose a key name**, format: `_` (e.g., `"Welcome"` → `Welcome_Text`, `"Save"` → `Save_Button`, `"Failed to save"` → `Save_Failed_Message`). Follow naming: PascalCase words, underscores between parts, descriptive enough to be unique. 2. **Add the label** to `force-app/main/default/labels/CustomLabels.labels-meta.xml`: ```xml Welcome_Text en_US false Welcome banner heading Welcome ``` (Full XML structure: [references/label-xml.md](references/label-xml.md)) 3. **Replace the string** in the component with `{t("Key")}`: ```tsx // Before:

Welcome

// After:

{t("Welcome_Text")}

``` 4. **Add the import** if not present: `import { useTranslation } from "react-i18next";` and `const { t } = useTranslation("c");` at the top of the component function. **Completion criterion:** Every confirmed string has both a `CustomLabels` entry and a `t()` call in its original location. **Pause:** "For each string I'll add a Custom Label and replace the JSX with t(). Here are the proposed keys: [show string → namespace:Key mapping]. Apply these edits? [y / review each]" --- ### Step 3: Register **Goal:** Add each key to the label manifest so i18next knows to fetch it. **Action:** 1. Add each key to the manifest array in `src/i18n/label-manifest.ts`: ```typescript export const labelManifest = [ "c:Welcome_Text", "c:Save_Button", "c:Save_Failed_Message", ]; ``` If the file doesn't exist yet, Step 4 scaffolds it; the completion check below reports its absence, so don't test for the file by hand. **Completion criterion:** Run `check-manifest-registered.sh` from the UI bundle dir (it scans `src/` relative to the current directory) and report any errors it returns. It owns the deterministic inspection: it cross-checks every `t("Key")` call site against the manifest and treats a missing `label-manifest.ts` (when `t()` calls exist) as a failure. A key that's called but not registered renders as its own literal name at runtime with no error, the silent-fail trap this guards. ```bash cd / dir> # scripts scan src/ relative to here bash /scripts/check-manifest-registered.sh ``` Branch on the exit code: `0`, every key is registered (or there are no `t()` calls to gate), proceed. `1`, the manifest is missing or the listed keys aren't in it; scaffold or add them (Step 4 scaffolds the file) and re-run. `64`, usage error, the source dir doesn't exist (wrong cwd or bad argument); this is **not** a "keys missing" result, do not scaffold or register, fix the path and re-run. **Pause:** "Added N entries to label-manifest.ts. check-manifest-registered.sh passed: [confirm]." --- ### Step 4: Wire **Goal:** Ensure the i18next init exists; scaffold it if the app has no i18n yet. **Check:** Run `check-i18n-wired.sh` from the UI bundle dir (it scans `src/` relative to the current directory) and report what it returns. The script owns the whole deterministic inspection: it looks for an init file defining `initI18n()` and a boot-time call to it, and when those exist it also reports whether the label manifest is imported and actually passed into the backend config. Do not re-derive any of this by reading files yourself. ```bash cd / dir> # scripts scan src/ relative to here bash /scripts/check-i18n-wired.sh ``` Branch on the **exit code** (the printed message names the specific file/symbol for your report, but the decision is the code): - Exit `0` → fully wired, the manifest is passed into the backend; go to "If i18n already exists" below and just add new keys. - Exit `1` → no `initI18n()` exists; scaffold the whole setup via "If no i18n setup exists yet". - Exit `2` → the init already exists but isn't called at boot; do **not** re-scaffold or overwrite it. Add only the boot-time `initI18n()` call in the entry file (step 4 of "If no i18n setup exists yet"), then re-run. - Exit `3` → wired at boot but the script **could not confirm** the manifest is passed into the backend. It scans the whole `src` tree, but this last check is a textual heuristic: the manifest may be wired through a variable, spread, or helper the script can't see, so treat exit 3 as "verify before editing," not "definitely broken." Open the file the message names and confirm the manifest really isn't in `backendOptions`. Only if it genuinely dangles, do what the message names: if the manifest is imported but unused, pass it into the existing `backendOptions` without clobbering it; if there's no `backendOptions`/`SalesforceBackend` config at all, add that backend block to the existing init (see [references/i18n-setup.md](references/i18n-setup.md)). Never re-scaffold the init file or duplicate wiring that already works. - Exit `64` → usage error: the source dir doesn't exist (wrong cwd or bad argument). This is **not** a "no init" result; do not scaffold. Fix the path (run from the UI bundle dir, or pass its `src` path) and re-run. **If no i18n setup exists yet:** 1. Install dependencies (tell the user to run): ```bash npm install i18next react-i18next i18next-chained-backend i18next-localstorage-backend ``` 2. Create `src/i18n/index.ts` with the init wiring (full code: [references/i18n-setup.md](references/i18n-setup.md)) 3. Create `src/i18n/label-manifest.ts` with an empty array (Step 3 will populate it) 4. Call `initI18n()` once at boot in the entry file (before mounting the app): ```typescript import { initI18n } from "./i18n"; initI18n().then(() => { // mount app }); ``` **If i18n already exists:** Act on the message `check-i18n-wired.sh` already printed (above): if it reports the manifest wired, just add new keys to it; if it reports a reconcile is needed, do exactly what its message names (import the manifest and/or pass it into the backend config) without clobbering existing wiring. **Completion criterion:** `initI18n()` exists and is called once at boot; the manifest is wired into the backend. **Pause:** "i18next setup [exists / created]. initI18n() is called at boot: [confirm]." --- ### Step 5: Verify **Goal:** Guide the developer to verify labels render in a second language. **Action:** 1. **Activate a second language** (if not already active), tell the user: "In your org, go to Setup → Translation Workbench → Translation Settings → add a language (e.g., Spanish)." 2. **Author a translation**, scaffold an empty translation file for the language: ```xml Welcome_Text ``` (Full structure: [references/label-xml.md](references/label-xml.md)) Tell the user to either: - Edit the XML file by hand (for a small number of labels), or - Use Translation Workbench (Setup → Translate → Custom Label → pick language → enter translations), then retrieve with `sf project retrieve start --metadata Translations:es`. 3. **Build and deploy**, tell the user: ```bash sf config set target-org= # API version bakes in; point at the deploy target first npm run build sf project deploy start --source-dir force-app --target-org ``` 4. **Open the app** at the `/lwr/application/ai/-` URL on the `lightning.force.com` domain (redirects to the app host). 5. **Change the user's Language** (not Locale), Setup → My Settings → Language & Time Zone → Language → pick the translated language → Save. 6. **Reload the app**, labels should flip to the translated language. **If it doesn't render:** Check the three gotchas in [references/gotchas.md](references/gotchas.md): - Unregistered manifest key (Step 3 missed a label) - API-version mismatch (built against a different org) - Stale localStorage cache (clear `i18next_res_*` keys in DevTools) **Completion criterion:** Labels render in ≥2 locales, or the blocking gotcha is identified. **Pause:** "To verify: activate a second language in Translation Workbench, author a translation (I can scaffold the XML), build/deploy, and reload. Want me to scaffold the translation file for [language]? [y / I'll do it manually]" --- ## Edge cases: handle gracefully - **Already-localized code**: detect existing `t()` usage / a populated manifest; offer to *add to* the setup rather than re-scaffold everything. - **No strings found**: report cleanly and stop; do not invent work. - **App has no i18n setup yet**: Step 4 scaffolds the two files first before Step 3 can register anything. - **Partial setup** (manifest exists but init missing, or vice-versa), reconcile what's present; never clobber existing wiring. --- ## Guardrails: never regress these 1. **Never machine-translate into deployable metadata.** Scaffold empty translation files and guide the developer to author translations (by hand or via Translation Workbench). Do not call any MT API and paste the result into `translation-meta.xml`; unreviewed machine translations are a quality liability. 2. **Never register a key that has no label.** Manifest entry count must equal label count (Step 3 criterion). An unregistered key renders as its own literal name with no console warning. It's the most common localization bug. 3. **Never clobber existing i18n wiring.** If Step 4 finds an existing `initI18n()`, reconcile (add the manifest import if missing) rather than replace the whole file. 4. **Every file must be customer-safe.** No `webapps`, core-only paths, or internal infrastructure references anywhere. Write as if for an external customer in an SFDX project. --- ## Commands & layout ```text / ← SFDX project root └── force-app/main/default/ ├── labels/CustomLabels.labels-meta.xml ← English base labels ├── translations/.translation-meta.xml ← one per translated language └── uiBundles// ├── package.json └── src/ ├── i18n/ │ ├── index.ts ← init wiring (you write this once) │ └── label-manifest.ts ← list of labels to fetch (you maintain this) └── components/ ← components call t() ``` | Command | Run from | Purpose | |---|---|---| | `npm install i18next react-i18next i18next-chained-backend i18next-localstorage-backend` | UI bundle dir | Install i18n dependencies (Step 4) | | `npm run build` | UI bundle dir | Build the app (API version bakes in, set target-org first) | | `sf project deploy start --source-dir force-app` | Project root | Deploy the app + labels + translations | | `sf project retrieve start --metadata Translations:` | Project root | Pull translations authored in Translation Workbench | --- ## Pre-flight checklist: completion criteria for the whole run - [ ] Every confirmed string has both a `CustomLabels` entry and a `t()` call - [ ] `label-manifest.ts` entry count == label count (no unregistered keys) - [ ] `initI18n()` present and called once at boot - [ ] Labels render in ≥2 locales (or the blocking gotcha is named) - [ ] No hand-written machine translations landed in `*-meta.xml` (only scaffold-and-guide)