Skip to content

feat(ios): resolve consumer locales against shipped translation bundles#492

Draft
jkmassel wants to merge 2 commits intojkmassel/locale-resolver-androidfrom
jkmassel/locale-resolver-ios
Draft

feat(ios): resolve consumer locales against shipped translation bundles#492
jkmassel wants to merge 2 commits intojkmassel/locale-resolver-androidfrom
jkmassel/locale-resolver-ios

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

@jkmassel jkmassel commented May 5, 2026

Summary

  • Adds the foundational pieces for the setLocale resolution work in Resolve consumer locales against shipped translation bundles #490: a Vite plugin that emits dist/supported-locales.json and a JS-side resolver that runs the chain full-tag → language-only → en.
  • Adds an iOS LocaleResolver and a new EditorConfigurationBuilder.setLocale(_ locale: Locale) method that resolves against the shipped manifest before storing the tag for serialization.
  • Drops the public setLocale(_ String) overload. It is replaced by an internal setLocaleTag(_ String) reserved for toBuilder round-trip and tests; external consumers must go through the Locale API.
  • Android counterpart in a sibling PR (also targets 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.locale is an opaque string. iOS consumers (WP-iOS) hand whatever the platform returns (Locale.identifier, Locale.preferredLanguages.first, etc.) into setLocale(String), and src/utils/localization.js does a single-level dynamic import that silently falls back to English on any miss. The supported list lived only in bin/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 emitSupportedLocalesManifest plugin scans src/translations/ at build time and emits dist/supported-locales.json (sorted array of locale tags). The existing make copy-dist-ios target ships it into the resource bundle.

Resolution chain

For an input xx-yy, normalised to lowercase with _-:

  1. Full tag (xx-yy) — match if shipped
  2. Language-only tag (xx) — match if shipped
  3. Fall back to en

Implemented in:

  • src/utils/localization.jsresolveLocale(input, supported?). Default supported set comes from import.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 around setLocaleData stays as defense-in-depth.
  • ios/Sources/GutenbergKit/Sources/Model/LocaleResolver.swiftLocaleResolver struct, fed by GutenbergKitResources.loadSupportedLocales() reading the manifest from Bundle.module.

API change

public func setLocale(_ locale: Locale) -> EditorConfigurationBuilder        // new — runs resolution
internal func setLocaleTag(_ tag: String) -> EditorConfigurationBuilder      // toBuilder + tests only

The previously-public setLocale(_ String) is removed. Consumers must pass a Locale value; the resolver decides the wire-format string. The internal setLocaleTag exists so toBuilder can round-trip a stored tag without re-running resolution.

Tests

  • Curated tests cover the resolution chain (full-tag → language-only → en fallback, normalisation of pt_BR / EN_GB / etc.).
  • A parameterised test asserts that every locale in the shipped manifest resolves to itself — catches regressions where a locale gets added but the resolver mishandles it. The list comes from GutenbergKitResources.loadSupportedLocales() (Swift) and import.meta.glob (JS), both reading the same source of truth as the production resolver.

What We Explored

  • Hard-coding the supported set in native code — the footgun the issue calls out. Rejected.
  • Reading the locale list from filenames in the bundle — Vite hashes the chunk filenames (pt-br-UCkBcRdR.js) and prefixes can collide (nl-be-... vs nl-...), so a parser would have to re-encode the SUPPORTED_LOCALES list anyway. A manifest is simpler and unambiguous.

Behaviour change

Input Before After
setLocale(Locale(identifier: "pt_BR")) (didn't exist) pt-br
setLocale(Locale(identifier: "fr_CA")) (didn't exist) fr ✅ (regional bundle absent → language fallback)
setLocale("pt_BR") (was opaque) pt_BR (no match → English) does not compile
JS receives pt_BR from native English pt-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 pass
  • npm run lint:js on the changed files — clean
  • Manual smoke: build the iOS demo, point it at Locale(identifier: "pt_BR") / Locale(identifier: "fr_CA"), confirm the editor UI renders in Brazilian Portuguese / French (not English).

Out of scope

  • Changing the SUPPORTED_LOCALES list itself.
  • Changing the wire format of EditorConfiguration.locale — still an opaque string on the JS side.
  • Pluralization / RTL handling — separate concerns.

Related

@github-actions github-actions Bot added the [Type] Enhancement A suggestion for improvement. label May 5, 2026
@jkmassel jkmassel force-pushed the jkmassel/locale-resolver-ios branch from 73db09a to 80c4ac3 Compare May 5, 2026 16:31
jkmassel added 2 commits May 5, 2026 16:34
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 jkmassel force-pushed the jkmassel/locale-resolver-ios branch from 38a4063 to c59cf00 Compare May 5, 2026 22:35
@jkmassel jkmassel changed the base branch from trunk to jkmassel/locale-resolver-android May 5, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant