feat(ios): resolve consumer locales against shipped translation bundles - #492
Draft
jkmassel wants to merge 4 commits into
Draft
feat(ios): resolve consumer locales against shipped translation bundles#492jkmassel wants to merge 4 commits into
jkmassel wants to merge 4 commits into
Conversation
5 tasks
jkmassel
force-pushed
the
jkmassel/locale-resolver-ios
branch
from
May 5, 2026 16:31
73db09a to
80c4ac3
Compare
Adds a Vite build plugin that scans `src/translations/` and emits `dist/supported-locales.json` — a sorted array of the locale tags we actually ship. The native iOS and Android sides consume this so the "what do we ship?" answer has exactly one source of truth. Also switches `loadTranslations` from a dynamic `import()` (which threw on a missing locale and fell back to English from the catch) to an `import.meta.glob` lookup that returns early with a warning when the tag isn't in the static map. Same exact-match-or-English behaviour, but the loader map is enumerable at build time so the failure mode is explicit rather than catch-driven. The native side now resolves consumer-supplied locales to a shipped tag before the value reaches JS, so the JS load path doesn't need its own resolver — anything not in the static glob is a bug upstream and falls back to English with a warn().
…undles Adds an Android `LocaleResolver` and a new `EditorConfiguration.Builder.setLocale(locale: Locale)` that resolves against a compile-time-generated set of shipped locales before storing the tag for serialization. The previously-public `setLocale(String?)` overload is removed; an `internal` `@JvmSynthetic setLocaleTag(String?)` is reserved for `toBuilder` round-trip and tests. The set of shipped locales is generated into a Kotlin `internal object SupportedLocales` at build time from the JS-side manifest, so a missing manifest fails the gradle build instead of silently falling through to English at runtime. The Gradle task uses `JsonSlurper` to parse the manifest and validates each entry is a string; non-string entries fail the task with a clear message. ## Resolution chain For an input locale, normalised to lowercase with `_` → `-`: 1. Full tag (`xx-yy`) — match if shipped 2. Script-implied region for macrolanguages we ship disjoint regional bundles for (e.g. `zh-Hant-HK` → `zh-tw`, `zh-Hans` → `zh-cn`) 3. Language-only tag (`xx`) — match if shipped 4. Fall back to `en` Inputs are parsed as BCP-47 via `Locale.forLanguageTag`, so script- tagged inputs like `zh-Hans-CN` collapse to `zh-cn` rather than falling through to English. Variant and Unicode-extension subtags (e.g. `de-DE-u-ca-gregory`) are ignored — the editor doesn't vary translations by calendar or numbering system. Legacy ISO 639-1 codes that Android's `Locale` class still emits (`iw` → `he`, `in` → `id`, `no` → `nb`) are aliased to canonical bundle names before lookup, so Hebrew/Indonesian/Norwegian users on devices reporting the legacy codes don't silently land on English. ## Why a Locale and not a String A locale string is a lossy encoding of what the system actually knows. Android hands the consumer a `Locale` — language, region, script, variant, extensions — and any boundary that flattens that to a string before the library decodes it throws data away. Taking a `Locale` at the boundary keeps signal the string would have dropped: the script subtag lets `zh-Hant-HK` resolve to `zh-tw` instead of English, and Android's legacy ISO 639-1 codes get aliased to the canonical bundle names before lookup. ## Tests - Curated `LocaleResolverTest` covers the resolution chain (full-tag → script-implied region → language-only → `en` fallback, normalisation of `pt_BR` / `EN_GB` / etc., script subtags, legacy alias mapping). - A parameterised test asserts that every locale in the generated `SupportedLocales.ALL` resolves to itself — catches regressions where a locale gets added but the resolver mishandles it. Reads from the generated constant directly, so the test can never drift from what the resolver actually uses in production. - Builder-level integration test exercises `setLocale(Locale)` through to `config.locale` against the shipped manifest. `make test-android` now depends on `make build` so the manifest is populated before the exhaustive test runs. Refs #490.
Counterpart to the Android `LocaleResolver` that landed in the parent
commit on this branch. Moves locale resolution into the library so
WP-iOS stops handing the editor an opaque tag and getting silently
dropped into the English bundle whenever the device locale doesn't
match a shipped `translations/<tag>.json` filename.
Resolution chain (mirrors Android):
1. Full `language-region` tag
2. Script-implied region — `zh-Hant-HK` → `zh-tw`, `zh-Hans` → `zh-cn`
3. Language-only tag
4. `en`
Inputs are parsed via `Locale(identifier:)` after `_` → `-`
normalisation, so `setLocale(Locale(identifier: "pt_BR"))` lands on
`pt-br`, `Locale(identifier: "zh-Hant-HK")` on `zh-tw`, and
`Locale(identifier: "iw_IL")` on `he`. Foundation already canonicalises
the legacy ISO 639-1 codes (`iw`/`in`/`no`) when parsing identifiers, so
the alias map is defense-in-depth on iOS — kept for symmetry with
Android in case Foundation ever changes.
One iOS-specific behaviour worth knowing: Foundation supplies an
implicit script for bare-language tags (`zh` → `Hans`, `ja` → `Jpan`),
so a bare `zh` lands on `zh-cn` via the script-implied step instead of
falling through to English. Android's `Locale.forLanguageTag` leaves the
script unset, so the same bare tag falls through there. The exhaustive
manifest round-trip test asserts the contract either way.
API change: the previously-public `setLocale(_ String)` is removed in
favour of `setLocale(_ Locale)`. The internal `setLocaleTag(_ String)`
exists so `toBuilder` can round-trip a stored tag without re-running
resolution. Migration is mechanical
(`setLocale("pt_BR")` → `setLocale(Locale(identifier: "pt_BR"))`).
Resources: `GutenbergKitResources.loadSupportedLocales()` reads the
manifest from `Bundle.module` at runtime. The follow-up build-plugin
commit on this branch replaces it with a compile-time constant.
Introduce a `BuildToolPlugin` that reads the JS-emitted
`supported-locales.json` manifest before `GutenbergKit` compiles and
emits an `internal enum SupportedLocales { static let all: Set<String> }`
constant. The resolver consumes the constant directly, dropping the
runtime IO that the previous commit shipped.
This mirrors the Android sibling's `:Gutenberg:generateSupportedLocales`
gradle task: a missing manifest fails the build (SwiftPM reports it as a
missing input), so a release that silently degrades every consumer to
English is unreachable in shipped artifacts. SwiftPM's incremental graph
treats the manifest as an input file, so changes from `make build`
trigger regeneration without rebuilding the whole target.
Layout:
- `ios/Plugins/SupportedLocalesPlugin/` — the plugin itself; computes the
manifest URL via `context.package.directoryURL` and wires up a
buildCommand against the generator tool.
- `ios/Plugins/GenerateSupportedLocales/` — small `executableTarget` the
plugin invokes. Reads the manifest, validates it's a JSON array of
strings, writes the Swift source. Friendly error messages for
missing/malformed input as a backstop in case SwiftPM's prebuild check
is bypassed.
`GutenbergKitResources.loadSupportedLocales()` is removed since it now
has no callers.
jkmassel
force-pushed
the
jkmassel/locale-resolver-ios
branch
from
May 5, 2026 22:35
38a4063 to
c59cf00
Compare
dcalhoun
added a commit
that referenced
this pull request
Jul 27, 2026
The demo app never called `setLocale`, so the editor always ran in English regardless of the scheme's *App Language* setting. Testing a translation meant editing code. Resolve the launch language against the locales the editor ships and pass the result through `applyDemoAppDefaults`, the single funnel every demo configuration already flows through. Read `Locale.preferredLanguages` rather than `Bundle.main.preferredLocalizations`. The latter filters against the localizations the app bundle itself ships, and the demo app ships only English, so every selection would collapse to `en`. Surface the resolved locale in the configuration details, noting the requested language when it differs. A language with no shipped bundle renders in English, which is otherwise indistinguishable from the selection being ignored. Xcode's right-to-left pseudolanguages are explicitly not supported. They are layout overrides rather than languages — Xcode passes `-AppleTextDirection YES -NSForceRightToLeftWritingDirection YES` with no `-AppleLanguages` — so UIKit mirrors the surrounding app while the editor, which renders in a web view and keys off the locale, does not. Testing right-to-left rendering means selecting a real language such as Arabic or Hebrew. `DemoAppLocale` duplicates the resolution chain that #492 adds to the library as `LocaleResolver`, matching Android's already merged in #493. It is scoped to one file so reviving #492 removes it wholesale, leaving a one-line call change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dcalhoun
added a commit
that referenced
this pull request
Jul 30, 2026
* feat: derive and apply locale text direction The editor renders every locale left-to-right. `src/index.html` hardcodes `lang="en"` with no `dir`, and nothing sets them at runtime, so the four right-to-left bundles we ship (`ar`, `fa`, `he`, `ur`) get English phonetics from screen readers and an LTR layout. In WordPress, core establishes this: it renders `<html lang dir>`, `<body class="rtl">`, and populates the `text direction` string backing `isRTL()`. GutenbergKit loads a static `index.html`, so nothing plays that role. Derive direction from the resolved locale — a fixed property of the language, already resolved to a shipped tag before it reaches JS — and apply it to both the document and `@wordpress/i18n`. The `setLocaleData` entry matters independently of CSS: components across `components`, `block-editor`, `block-library`, and `editor` call `isRTL()` at runtime to pick icons, accessibility labels, keyboard navigation, and drop-zone geometry. The string is injected rather than read from the bundle because the `wp-plugins/gutenberg` GlotPress project doesn't carry it — it belongs to core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: load stylesheets matching the editor text direction The editor imported only the left-to-right Gutenberg stylesheets, so right-to-left locales rendered translated strings in a mirrored layout: logical properties resolved backwards, and rules Gutenberg guards with `body.rtl` or `html[dir=rtl]` never matched. Import both variants and inject one at runtime. The `-rtl` bundles are full rewrites rather than overrides — across the five editor stylesheets roughly 690 selectors appear in both files with conflicting declarations and almost no `[dir=rtl]` scoping — so loading both would let source order decide the direction every user gets. WordPress swaps the enqueued file server-side; the equivalent choice happens here because the editor loads a single static `index.html`. Both variants ship in the bundle rather than being fetched on demand. The assets are already bundled into the host app, so the added weight costs no network time, and selecting synchronously avoids introducing async work before first paint. `default-editor-styles.css` is left alone: it and its `-rtl` sibling are byte-identical, containing nothing directional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(demo-ios): forward Xcode's App Language to the editor The demo app never called `setLocale`, so the editor always ran in English regardless of the scheme's *App Language* setting. Testing a translation meant editing code. Resolve the launch language against the locales the editor ships and pass the result through `applyDemoAppDefaults`, the single funnel every demo configuration already flows through. Read `Locale.preferredLanguages` rather than `Bundle.main.preferredLocalizations`. The latter filters against the localizations the app bundle itself ships, and the demo app ships only English, so every selection would collapse to `en`. Surface the resolved locale in the configuration details, noting the requested language when it differs. A language with no shipped bundle renders in English, which is otherwise indistinguishable from the selection being ignored. Xcode's right-to-left pseudolanguages are explicitly not supported. They are layout overrides rather than languages — Xcode passes `-AppleTextDirection YES -NSForceRightToLeftWritingDirection YES` with no `-AppleLanguages` — so UIKit mirrors the surrounding app while the editor, which renders in a web view and keys off the locale, does not. Testing right-to-left rendering means selecting a real language such as Arabic or Hebrew. `DemoAppLocale` duplicates the resolution chain that #492 adds to the library as `LocaleResolver`, matching Android's already merged in #493. It is scoped to one file so reviving #492 removes it wholesale, leaving a one-line call change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Apply border styles for RTL layouts Absolutely targeting the right border resulted in unexpected styling for RTL language layout. * feat(demo-android): forward the per-app language to the editor The demo app never called `setLocale`, so the editor always ran in English regardless of the device or per-app language. Testing a translation — or right-to-left rendering — meant editing code. Declare a `localeConfig` so the app appears in the system's per-app language picker, read the selection, and pass it to both configuration paths. No resolution logic is needed on this side: `EditorConfiguration.Builder.setLocale(Locale)` already resolves against the bundled translations via the library's `LocaleResolver`. The selection is read from the platform's `LocaleManager` rather than `AppCompatDelegate.getApplicationLocales()`. That helper resolves the application locale by walking appcompat's registry of live activity delegates, and every activity in this app extends `ComponentActivity` rather than `AppCompatActivity`, so the registry is always empty and the helper reports no selection regardless of what the system holds. The configuration is rebuilt when the locale changes. Android recreates the activity on a locale change, but the view model survives it, so loading the configuration from `LaunchedEffect(Unit)` would keep serving the one built with the previous locale. The offered languages are a curated subset rather than all ~49 shipped locales, each covering a distinct rendering path: `ar` for right-to-left with cursive shaping, `he` for right-to-left without it, `ja` for CJK glyph selection and line breaking, `pt-BR` for the resolver's regional step, and `en`/`es`/`fr` as Latin baselines. Surface the resolved locale in the configuration details, linking to the system picker. A language with no bundled translations resolves to `en`, which is otherwise indistinguishable from the selection being ignored. The link is omitted below Android 13, which has no per-app language screen to open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Update asymmetric properties for RTL language layouts Avoid absolute direction styles that break RTL language layouts. * fix: Remove unused styles The relevant UI element no longer exists. * fix(demo-android): refresh the locale when returning from the picker Tapping "Change" and selecting a language left the Editor Configuration screen showing the previous locale. Backing out and re-entering was required to see the new one. The locale was read once during composition. Changing the per-app language does not necessarily recreate the activity — the system picker belongs to another task, so this activity is merely stopped and resumed — and nothing prompted the composition to re-read the value on return. Re-read the locale on `ON_RESUME` and drive the configuration reload from that state, so both paths are covered: activity recreation where it happens, and a plain resume where it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: Reduce comment verbosity Remove comments deemed unnecessary. * refactor(demo-ios): move DemoAppLocale into Services The type reads platform state and resolves it to a locale — the same kind of thing as the other members of `Services`. It was placed under `Views` only because that group is file-system synchronized, so files added there compile without editing `project.pbxproj`. Drop the note explaining that placement, which no longer applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ios): declare the editor's language to assistive technology VoiceOver read the native block inserter with the host app's default speech voice, so an Italian block list was announced by an English engine. The web content sets `documentElement.lang`, which VoiceOver honors, but the native surfaces are a separate accessibility tree that declared nothing and fell back to the app's language. Set `accessibilityLanguage` on the inserter's hosting view, and on the camera and patterns sheets. `accessibilityLanguage` is inherited down a view hierarchy, but a sheet is presented as a sibling rather than a descendant, so each presentation boundary has to declare it. The locale travels through the SwiftUI environment, which does cross sheets. SwiftUI has no equivalent modifier — `accessibilityLanguage` exists only on `UIView` and `UIAccessibilityElement` — so `editorAccessibilityLanguage()` bridges to UIKit through a representable. Strings the host supplies rather than the editor are covered too. A host localizes itself to the same locale it passes to `setLocale`, so its strings are in that language; where they are not, the host is shipping untranslated strings and the library should not model around it. The system photo picker renders out of process and owns its own accessibility tree, so it cannot be annotated from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(demo-ios): stop English falling through to the next preferred language With English first and French second in iOS's preferred languages, the editor loaded French. With English alone it loaded English. English is the editor's source language, so no `en` bundle ships and the lookup cannot match it. The resolver treated that miss as "this language is unavailable, try the next one" and moved on to French — but the user's first choice was English, and English is exactly what the editor renders without a bundle. It only appeared correct with a single preferred language because the loop then ran out and hit the same default. Stop the search on any English tag. Regional variants that do ship — `en-gb`, `en-au` — still match before that check, and genuinely unshipped languages still fall through. Describe the outcome accurately too: "fr — resolved from en-US" implied `en-US` legitimately maps to French, and "en — no bundle, using default from en-US" would still misdescribe asking for English and getting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ios): guard EditorAccessibilityLanguage behind canImport(UIKit) `swift test` builds the package for the host platform, where UIKit is unavailable, so the `UIViewRepresentable` bridge failed to compile and took the library test suite with it. Wrap the file in `#if canImport(UIKit)`, matching the sibling views. Both call sites already sit inside the same guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: inject the editor styles before the existing stylesheets The stylesheets moved from a side-effect import to a runtime injection so the direction variant can be chosen at load. Vite hoisted the import ahead of `index.scss`, while appending places it after the stylesheet link. These stylesheets are the base layer GutenbergKit's own styles build on, and several selectors tie on specificity across the two, which the cascade then resolves by source order. Insert before the first stylesheet to preserve the order the import produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: correct the toolbar scroll indicators for RTL layouts In a right-to-left container `scrollLeft` is `0` at the right edge and grows negative moving left, so comparing it directly against zero left the left gradient permanently hidden and the right gradient shown even at the start edge. Normalize the offset to a distance from the start, then map it onto the physical edges the gradients are anchored to, which do not flip with the writing direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: derive the editor direction from a single resolution `configureLocale` resolved the text direction to set on the document, then `setUpEditorEnvironment` resolved it a second time from the raw `getGBKit().locale` to choose the stylesheets. The two agree today only because `isRTLLocale(undefined)` happens to match the `en` default, so a different default or a normalized locale value would leave the chrome and canvas stylesheets disagreeing on direction. Return the resolved direction from `configureLocale` and pass it through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: avoid resolving computed style on every toolbar scroll `updateScrollState` is the scroll listener, so reading the container's computed `direction` forced a style resolution on every frame of a touch-dragged toolbar. The direction is set once at startup and fixed for the editor's lifetime. Read `documentElement.dir` instead, matching how the visual editor already selects its stylesheets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ios): apply the accessibility language once the view is in a hierarchy `updateUIView` runs before the representable's view is necessarily attached to a superview, and with no superview the walk up the responder chain finds no hosting controller. The language never changes afterward, so SwiftUI had no reason to call `updateUIView` again and the miss was permanent — VoiceOver would read localized strings with the device voice. Re-apply on move to a window so the assignment retries once the hierarchy exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(demo-android): guard the per-app language picker against no handler `ACTION_APP_LOCALE_SETTINGS` is optional even on API 33+ and some devices ship no handler for it, so gating the row on the SDK level alone meant tapping "Editor Locale" threw an uncaught `ActivityNotFoundException`. Resolve the intent up front and fall back to the plain read-only row. Also drop a local left unused by the resume-aware locale read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(demo-ios): match English by language subtag rather than prefix `hasPrefix("en")` matches any tag starting with those two letters, such as `enm` for Middle English, rather than the English language subtag. Reuse `DemoAppLocale.isEnglish`, which already parses the subtag for the same purpose during resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ios): declare the accessibility language once on the editor `accessibilityLanguage` was applied at every presentation boundary: an imperative assignment on the block inserter's hosting controller, and an `editorAccessibilityLanguage()` modifier on the sheets presented from it. The modifier existed because modal presentations are siblings of their presenter rather than descendants, which was assumed to break inheritance. Testing showed it does not. With the app running in English and the editor locale pinned to French, a single assignment on `EditorViewController.view` gives a matching speech voice on the editor chrome, the native inserter, the patterns sheet, and the camera sheet — identical to annotating each boundary, and confirmed against WordPress-iOS. Removes `EditorAccessibilityLanguage.swift` along with the `UIViewRepresentable` shim, its responder-chain walk, and the `didMoveToWindow` retry that existed only to work around the shim's timing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
setLocaleresolution work in Resolve consumer locales against shipped translation bundles #490: a Vite plugin that emitsdist/supported-locales.jsonand a JS-side resolver that runs the chain full-tag → language-only →en.LocaleResolverand a newEditorConfigurationBuilder.setLocale(_ locale: Locale)method that resolves against the shipped manifest before storing the tag for serialization.setLocale(_ String)overload. It is replaced by aninternalsetLocaleTag(_ String)reserved fortoBuilderround-trip and tests; external consumers must go through theLocaleAPI.trunk); the shared JS pieces appear in both and the second to land is a no-op for those files.Refs #490.
Root Cause
EditorConfiguration.localeis an opaque string. iOS consumers (WP-iOS) hand whatever the platform returns (Locale.identifier,Locale.preferredLanguages.first, etc.) intosetLocale(String), andsrc/utils/localization.jsdoes a single-level dynamicimportthat silently falls back to English on any miss. The supported list lived only inbin/prep-translations.js, so consumers had to mirror that table to do the right thing — and historically hadn't.Changes
Build
vite.config.js: New
emitSupportedLocalesManifestplugin scanssrc/translations/at build time and emitsdist/supported-locales.json(sorted array of locale tags). The existingmake copy-dist-iostarget ships it into the resource bundle.Resolution chain
For an input
xx-yy, normalised to lowercase with_→-:xx-yy) — match if shippedxx) — match if shippedenImplemented in:
resolveLocale(input, supported?). Default supported set comes fromimport.meta.glob('../translations/*.json'), so JS is statically analysable and gets the same single source of truth without a separate fetch. The catch-and-warn aroundsetLocaleDatastays as defense-in-depth.LocaleResolverstruct, fed byGutenbergKitResources.loadSupportedLocales()reading the manifest fromBundle.module.API change
The previously-public
setLocale(_ String)is removed. Consumers must pass aLocalevalue; the resolver decides the wire-format string. The internalsetLocaleTagexists sotoBuildercan round-trip a stored tag without re-running resolution.Tests
enfallback, normalisation ofpt_BR/EN_GB/ etc.).GutenbergKitResources.loadSupportedLocales()(Swift) andimport.meta.glob(JS), both reading the same source of truth as the production resolver.What We Explored
pt-br-UCkBcRdR.js) and prefixes can collide (nl-be-...vsnl-...), so a parser would have to re-encode the SUPPORTED_LOCALES list anyway. A manifest is simpler and unambiguous.Behaviour change
setLocale(Locale(identifier: "pt_BR"))pt-br✅setLocale(Locale(identifier: "fr_CA"))fr✅ (regional bundle absent → language fallback)setLocale("pt_BR")(was opaque)pt_BR(no match → English)pt_BRfrom nativept-br✅ (JS-side resolver)Removing the string overload is a breaking change for any caller that was passing a raw string. The migration is mechanical (
setLocale("pt_BR")→setLocale(Locale(identifier: "pt_BR"))).Test plan
npx vitest run src/utils/localization.test.js— 54/54 pass (4 curated + 1 sanity + 49 per-locale)swift test --filter "EditorConfigurationBuilderTests|LocaleResolverTests"— 38 + 49-case parameterised, all passnpm run lint:json the changed files — cleanLocale(identifier: "pt_BR")/Locale(identifier: "fr_CA"), confirm the editor UI renders in Brazilian Portuguese / French (not English).Out of scope
SUPPORTED_LOCALESlist itself.EditorConfiguration.locale— still an opaque string on the JS side.Related
trunk): see sibling PR.