Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package org.wordpress.gutenberg.model

import android.content.Context
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.net.URI
import java.util.Locale
import java.util.UUID

@Parcelize
Expand Down Expand Up @@ -97,6 +99,26 @@ data class EditorConfiguration(
fun setAuthHeader(authHeader: String) = apply { this.authHeader = authHeader }
fun setEditorSettings(editorSettings: String?) = apply { this.editorSettings = editorSettings }
fun setLocale(locale: String?) = apply { this.locale = locale }

/**
* Resolves [locale] against the bundled translations and stores the
* resulting tag for serialization.
*
* Prefer this overload when the locale comes from a platform API
* (e.g. [android.content.res.Configuration.getLocales] or the user's
* per-app locale). It runs the resolution chain — full tag →
* language-only tag → `en` — using the manifest shipped in this
* library's assets, so a device configured for `pt_BR` correctly
* lands on the `pt-br` bundle instead of silently falling through
* to English.
*
* @param context Used to read the shipped translations manifest
* from this library's assets.
* @param locale The platform locale to resolve.
*/
fun setLocale(context: Context, locale: Locale) = apply {
this.locale = LocaleResolver.fromAssets(context).resolve(locale)
}
fun setCookies(cookies: Map<String, String>) = apply { this.cookies = cookies }
fun setEnableAssetCaching(enableAssetCaching: Boolean) = apply { this.enableAssetCaching = enableAssetCaching }
fun setCachedAssetHosts(cachedAssetHosts: Set<String>) = apply { this.cachedAssetHosts = cachedAssetHosts }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package org.wordpress.gutenberg.model

import android.content.Context
import kotlinx.serialization.json.Json
import java.util.Locale

/**
* Resolves an arbitrary locale tag to one of the bundles GutenbergKit
* actually ships translations for.
*
* Consumers historically hand [EditorConfiguration] an opaque locale string
* — on Android, often the output of [Locale.getLanguage], which strips the
* region. The editor then silently falls back to English whenever the tag
* doesn't match a shipped `translations/<tag>.json` file exactly. The
* resolver moves that decision into the library, so a device configured for
* `pt_BR` ends up with the Brazilian Portuguese bundle — and a tag like
* `nl-BE`, for which we don't ship a regional bundle, falls back to `nl`
* instead of all the way to English.
*
* Resolution chain for an input `xx-yy`:
* 1. Full normalised tag (`xx-yy`)
* 2. Language-only tag (`xx`)
* 3. `en`
*
* The supported set is read from a manifest emitted by the JS build, so it
* stays in sync with what the bundle actually ships.
*/
class LocaleResolver internal constructor(supportedLocales: Collection<String>) {
private val supportedLocales: Set<String> =
supportedLocales.map { normalize(it) }.toSet()

/** Resolves a string locale tag against the shipped translation bundles. */
fun resolve(tag: String?): String {
if (tag.isNullOrEmpty()) return DEFAULT_LOCALE

val normalized = normalize(tag)
if (supportedLocales.contains(normalized)) return normalized

val language = normalized.substringBefore('-')
if (language.isNotEmpty() && supportedLocales.contains(language)) {
return language
}

return DEFAULT_LOCALE
}

/** Resolves a [Locale] against the shipped translation bundles. */
fun resolve(locale: Locale): String = resolve(locale.toLanguageTag())

companion object {
private const val DEFAULT_LOCALE = "en"
private const val MANIFEST_ASSET_PATH = "supported-locales.json"

/**
* Builds a resolver backed by the manifest shipped in `assets/`.
*
* Returns a resolver with an empty supported set when the manifest
* is missing or unreadable — callers will get [DEFAULT_LOCALE] for
* every input rather than crashing.
*/
@JvmStatic
fun fromAssets(context: Context): LocaleResolver {
val locales = try {
context.assets.open(MANIFEST_ASSET_PATH).use { stream ->
Json.decodeFromString<List<String>>(stream.bufferedReader().readText())
}
} catch (_: Exception) {
emptyList()
}
return LocaleResolver(locales)
}

private fun normalize(tag: String): String =
tag.lowercase(Locale.ROOT).replace('_', '-')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.wordpress.gutenberg.model

import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.Locale

class LocaleResolverTest {

// Stand-in for the manifest emitted at build time. Mirrors the real
// supported set closely enough to exercise both fallback steps.
private val resolver = LocaleResolver(
listOf(
"de", "en-gb", "es", "es-ar", "fr", "nl", "nl-be",
"pt", "pt-br", "zh-cn", "zh-tw"
)
)

@Test
fun `null and empty input fall back to English`() {
assertEquals("en", resolver.resolve(null))
assertEquals("en", resolver.resolve(""))
}

@Test
fun `full normalized tag is returned when shipped`() {
assertEquals("pt-br", resolver.resolve("pt-br"))
assertEquals("pt-br", resolver.resolve("pt-BR"))
assertEquals("pt-br", resolver.resolve("pt_BR"))
assertEquals("en-gb", resolver.resolve("EN_GB"))
assertEquals("zh-cn", resolver.resolve("zh-CN"))
}

@Test
fun `falls back to language-only tag when the regional bundle is absent`() {
// `fr-CA` not shipped, but `fr` is.
assertEquals("fr", resolver.resolve("fr-CA"))
// `de-AT` not shipped, but `de` is.
assertEquals("de", resolver.resolve("de-AT"))
}

@Test
fun `falls back to English when neither full nor language match`() {
// We ship `zh-cn`/`zh-tw` but no language-only `zh`. This is the
// real-world footgun the Brazilian/Chinese examples in issue 490
// describe — `Locale#getLanguage` returns just `zh`, which has
// historically dropped users into the English bundle.
assertEquals("en", resolver.resolve("zh"))
assertEquals("en", resolver.resolve("xx-yy"))
}

@Test
fun `resolves Locale values via toLanguageTag`() {
assertEquals("pt-br", resolver.resolve(Locale("pt", "BR")))
assertEquals("fr", resolver.resolve(Locale("fr", "CA")))
assertEquals("zh-cn", resolver.resolve(Locale.SIMPLIFIED_CHINESE))
// The footgun this issue fixes: WP-Android historically passed
// `Locale.getLanguage()` (just `zh`), which dropped Chinese users
// into English. The resolver still falls back to `en` for that
// bare tag because we ship no language-only `zh` bundle — but
// consumers who pass the full `Locale` now get `zh-cn`.
assertEquals("en", resolver.resolve(Locale("zh")))
}
}
12 changes: 12 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,18 @@ public struct EditorConfigurationBuilder {
return copy
}

/// Resolves `locale` against the bundled translations and stores the
/// resulting tag for serialization.
///
/// Prefer this overload when the locale comes from a platform API
/// (`Locale.current`, `Locale.preferredLanguages`, etc.). It runs the
/// resolution chain — full tag → language-only tag → `en` — so a device
/// configured for `pt_BR` correctly lands on the `pt-br` bundle instead
/// of silently falling through to English.
public func setLocale(_ locale: Locale) -> EditorConfigurationBuilder {
setLocale(LocaleResolver.default.resolve(locale))
}

public func setNativeInserterEnabled(_ isNativeInserterEnabled: Bool = true)
-> EditorConfigurationBuilder {
var copy = self
Expand Down
54 changes: 54 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Model/LocaleResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation
import GutenbergKitResources

/// Resolves an arbitrary locale tag to one of the bundles GutenbergKit
/// actually ships translations for.
///
/// Consumers (WP-iOS, WP-Android) historically hand `EditorConfiguration`
/// an opaque locale string and the editor silently falls back to English
/// whenever the tag doesn't match a shipped `translations/<tag>.json` file
/// exactly. The resolver moves that decision into the library, so a device
/// configured for `pt_BR` ends up with the Brazilian Portuguese bundle —
/// and a tag like `nl-BE`, for which we don't ship a regional bundle, falls
/// back to `nl` instead of all the way to English.
///
/// Resolution chain for an input `xx-yy`:
/// 1. Full normalised tag (`xx-yy`)
/// 2. Language-only tag (`xx`)
/// 3. `en`
///
/// The supported set is read from a manifest emitted by the JS build, so it
/// stays in sync with what the bundle actually ships.
struct LocaleResolver {
static let `default` = LocaleResolver()

private let supportedLocales: Set<String>

init(supportedLocales: [String]? = nil) {
let source = supportedLocales ?? GutenbergKitResources.loadSupportedLocales()
self.supportedLocales = Set(source.map { Self.normalize($0) })
}

/// Resolves a string locale tag against the shipped translation bundles.
func resolve(_ tag: String) -> String {
let normalized = Self.normalize(tag)
if supportedLocales.contains(normalized) {
return normalized
}
if let language = normalized.split(separator: "-").first.map(String.init),
!language.isEmpty,
supportedLocales.contains(language) {
return language
}
return "en"
}

/// Resolves a `Locale` value against the shipped translation bundles.
func resolve(_ locale: Locale) -> String {
resolve(locale.identifier(.bcp47))
}

private static func normalize(_ tag: String) -> String {
tag.lowercased().replacingOccurrences(of: "_", with: "-")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,30 @@ public enum GutenbergKitResources {
return url
}

/// Loads the list of locale tags for which the bundle ships translations.
///
/// The list is generated at JS build time by scanning `src/translations/`,
/// so it is the single source of truth for "what do we actually ship?".
/// Returns an empty array when the manifest is missing — callers should
/// treat that as "no shipped translations" and fall back to the default
/// locale rather than crashing.
///
/// - Returns: The shipped locale tags (e.g. `["ar", "de", "pt-br", "zh-cn"]`).
public static func loadSupportedLocales() -> [String] {
guard let url = Bundle.module.url(
forResource: "supported-locales",
withExtension: "json",
subdirectory: "Gutenberg"
) else {
return []
}
guard let data = try? Data(contentsOf: url),
let locales = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return locales
}

/// Loads the Gutenberg CSS from the bundled assets.
///
/// Scans the `Gutenberg/assets/` directory for the Vite-generated
Expand Down
14 changes: 14 additions & 0 deletions ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures {
#expect(config.locale == "fr_FR")
}

@Test("setLocale(Locale) resolves against the shipped translation bundles")
func setLocaleResolvesPlatformLocale() {
// Smoke test against the real bundle. We can only assert robust
// post-conditions because the manifest depends on `make build`
// having run, but the resolver always lands on either a shipped
// tag or the `en` fallback — never an unresolved regional tag.
let config = makeConfigurationBuilder()
.setLocale(Locale(identifier: "pt_BR"))
.build()

let lower = config.locale.lowercased()
#expect(lower == "pt-br" || lower == "pt" || lower == "en")
}

@Test("setNativeInserterEnabled updates isNativeInserterEnabled")
func setNativeInserterEnabledUpdates() {
let config = makeConfigurationBuilder()
Expand Down
50 changes: 50 additions & 0 deletions ios/Tests/GutenbergKitTests/Model/LocaleResolverTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import Testing

@testable import GutenbergKit

@Suite
struct LocaleResolverTests {
// Stand-in for the manifest emitted at build time. Mirrors the real
// supported set closely enough to exercise both fallback steps.
static let supported = [
"de", "en-gb", "es", "es-ar", "fr", "nl", "nl-be", "pt", "pt-br", "zh-cn", "zh-tw",
]

static let resolver = LocaleResolver(supportedLocales: supported)

@Test("Empty input falls back to English")
func emptyFallsBack() {
#expect(Self.resolver.resolve("") == "en")
}

@Test("Full normalized tag is returned when shipped")
func fullTagMatch() {
#expect(Self.resolver.resolve("pt-br") == "pt-br")
#expect(Self.resolver.resolve("pt-BR") == "pt-br")
#expect(Self.resolver.resolve("pt_BR") == "pt-br")
#expect(Self.resolver.resolve("EN_GB") == "en-gb")
}

@Test("Falls back to language-only tag when the regional bundle is absent")
func languageFallback() {
// `fr-CA` not shipped, but `fr` is.
#expect(Self.resolver.resolve("fr-CA") == "fr")
// `de-AT` not shipped, but `de` is.
#expect(Self.resolver.resolve("de-AT") == "de")
}

@Test("Falls back to English when neither full nor language match")
func englishFallback() {
// We ship `zh-cn`/`zh-tw` but no language-only `zh`.
#expect(Self.resolver.resolve("zh") == "en")
#expect(Self.resolver.resolve("xx-yy") == "en")
}

@Test("Resolves Locale values via BCP-47 identifier")
func resolveLocaleValue() {
#expect(Self.resolver.resolve(Locale(identifier: "pt_BR")) == "pt-br")
#expect(Self.resolver.resolve(Locale(identifier: "fr_CA")) == "fr")
#expect(Self.resolver.resolve(Locale(identifier: "zh_Hans")) == "en")
}
}
Loading
Loading