diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index acf34390..29a49a9a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -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 @@ -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) = apply { this.cookies = cookies } fun setEnableAssetCaching(enableAssetCaching: Boolean) = apply { this.enableAssetCaching = enableAssetCaching } fun setCachedAssetHosts(cachedAssetHosts: Set) = apply { this.cachedAssetHosts = cachedAssetHosts } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocaleResolver.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocaleResolver.kt new file mode 100644 index 00000000..29c4bc20 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/LocaleResolver.kt @@ -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/.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) { + private val supportedLocales: Set = + 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>(stream.bufferedReader().readText()) + } + } catch (_: Exception) { + emptyList() + } + return LocaleResolver(locales) + } + + private fun normalize(tag: String): String = + tag.lowercase(Locale.ROOT).replace('_', '-') + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/LocaleResolverTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/LocaleResolverTest.kt new file mode 100644 index 00000000..9e0b58b5 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/LocaleResolverTest.kt @@ -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"))) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 69661fac..d59a5949 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -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 diff --git a/ios/Sources/GutenbergKit/Sources/Model/LocaleResolver.swift b/ios/Sources/GutenbergKit/Sources/Model/LocaleResolver.swift new file mode 100644 index 00000000..4875f5de --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Model/LocaleResolver.swift @@ -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/.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 + + 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: "-") + } +} diff --git a/ios/Sources/GutenbergKitResources/Sources/GutenbergKitResources.swift b/ios/Sources/GutenbergKitResources/Sources/GutenbergKitResources.swift index 5fb0d8bf..3e76f5ec 100644 --- a/ios/Sources/GutenbergKitResources/Sources/GutenbergKitResources.swift +++ b/ios/Sources/GutenbergKitResources/Sources/GutenbergKitResources.swift @@ -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 diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index a69dc877..6c8d16f4 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -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() diff --git a/ios/Tests/GutenbergKitTests/Model/LocaleResolverTests.swift b/ios/Tests/GutenbergKitTests/Model/LocaleResolverTests.swift new file mode 100644 index 00000000..4a3a4d6e --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Model/LocaleResolverTests.swift @@ -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") + } +} diff --git a/src/utils/localization.js b/src/utils/localization.js index 842f8eed..3491cbb3 100644 --- a/src/utils/localization.js +++ b/src/utils/localization.js @@ -11,6 +11,15 @@ import { warn, debug } from './logger'; const DEFAULT_LOCALE = 'en'; +// Vite statically enumerates the translation bundles at build time, so the +// list of supported locales here is always in sync with what we actually ship. +const TRANSLATION_MODULES = import.meta.glob( '../translations/*.json' ); +const SUPPORTED_LOCALES = new Set( + Object.keys( TRANSLATION_MODULES ).map( ( p ) => + p.replace( /^\.\.\/translations\//, '' ).replace( /\.json$/, '' ) + ) +); + /** * Initializes i18n support for the editor. * @@ -22,26 +31,77 @@ export async function configureLocale() { } /** - * Loads translations for the specified locale from the downloaded files. + * Resolves an arbitrary locale tag against the bundles we actually ship. + * + * Tries the full tag, then the language-only tag, then falls back to + * `DEFAULT_LOCALE`. Inputs are normalised to lowercase with `_` replaced by + * `-` so that platform-native identifiers (e.g. `pt_BR`, `en_GB`) match the + * `pt-br` / `en-gb` filenames we ship. + * + * @param {string|null|undefined} input The locale tag from the consumer. + * @param {Set|Iterable} [supported] Override the supported set. + * Defaults to the bundles + * shipped in `src/translations/`. + * + * @return {string} A shipped locale tag, or `DEFAULT_LOCALE`. + */ +export function resolveLocale( input, supported = SUPPORTED_LOCALES ) { + if ( ! input ) { + return DEFAULT_LOCALE; + } + + const set = supported instanceof Set ? supported : new Set( supported ); + const normalized = String( input ).toLowerCase().replace( /_/g, '-' ); + + if ( set.has( normalized ) ) { + return normalized; + } + + const language = normalized.split( '-' )[ 0 ]; + if ( language && set.has( language ) ) { + return language; + } + + return DEFAULT_LOCALE; +} + +/** + * Loads translations for the specified locale from the bundled files. * * @param {string} locale The locale to load translations for. * * @return {Promise} A promise that resolves when translations are loaded. */ async function loadTranslations( locale ) { - if ( locale === DEFAULT_LOCALE ) { + const resolved = resolveLocale( locale ); + + if ( resolved !== locale ) { + debug( + `Resolved locale "${ locale }" to "${ resolved }" against bundled translations.` + ); + } + + if ( resolved === DEFAULT_LOCALE ) { return; } - try { - debug( 'Loading translations for', locale ); - const { default: translations } = await import( - `../translations/${ locale }.json` + const loader = TRANSLATION_MODULES[ `../translations/${ resolved }.json` ]; + if ( ! loader ) { + // `resolveLocale` already gates on `SUPPORTED_LOCALES`, so this + // branch only fires if the manifest and the bundle have drifted. + warn( + `Translations unavailable for locale "${ resolved }". Falling back to English.` ); + return; + } + + try { + debug( 'Loading translations for', resolved ); + const { default: translations } = await loader(); setLocaleData( translations ); } catch ( err ) { warn( - `Translations unavailable for locale "${ locale }". Falling back to English.` + `Translations unavailable for locale "${ resolved }". Falling back to English.` ); debug( 'Translation loading error details:', err ); } diff --git a/src/utils/localization.test.js b/src/utils/localization.test.js new file mode 100644 index 00000000..fe258efa --- /dev/null +++ b/src/utils/localization.test.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock( './logger.js', () => ( { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +} ) ); + +/** + * Internal dependencies + */ +import { resolveLocale } from './localization'; + +// Stand-in for the manifest emitted at build time. Mirrors the real +// supported set closely enough to exercise both fallback steps. +const SUPPORTED = [ + 'de', + 'en-gb', + 'es', + 'es-ar', + 'fr', + 'nl', + 'nl-be', + 'pt', + 'pt-br', + 'zh-cn', + 'zh-tw', +]; + +describe( 'resolveLocale', () => { + it( 'returns "en" for null/empty input', () => { + expect( resolveLocale( null, SUPPORTED ) ).toBe( 'en' ); + expect( resolveLocale( undefined, SUPPORTED ) ).toBe( 'en' ); + expect( resolveLocale( '', SUPPORTED ) ).toBe( 'en' ); + } ); + + it( 'matches the full normalized tag when shipped', () => { + expect( resolveLocale( 'pt-br', SUPPORTED ) ).toBe( 'pt-br' ); + expect( resolveLocale( 'pt-BR', SUPPORTED ) ).toBe( 'pt-br' ); + expect( resolveLocale( 'pt_BR', SUPPORTED ) ).toBe( 'pt-br' ); + expect( resolveLocale( 'zh-CN', SUPPORTED ) ).toBe( 'zh-cn' ); + expect( resolveLocale( 'EN_GB', SUPPORTED ) ).toBe( 'en-gb' ); + } ); + + it( 'falls back to the language-only tag when the regional bundle is absent', () => { + // `fr-CA` not shipped, but `fr` is. + expect( resolveLocale( 'fr-CA', SUPPORTED ) ).toBe( 'fr' ); + // `de-AT` not shipped, but `de` is. + expect( resolveLocale( 'de-AT', SUPPORTED ) ).toBe( 'de' ); + } ); + + it( 'falls back to "en" when neither full nor language match', () => { + // We ship `zh-cn`/`zh-tw` but no language-only `zh`, so a bare + // `zh` from a device locale should land on English. This is the + // real-world footgun the issue calls out. + expect( resolveLocale( 'zh', SUPPORTED ) ).toBe( 'en' ); + expect( resolveLocale( 'xx-yy', SUPPORTED ) ).toBe( 'en' ); + } ); +} ); diff --git a/vite.config.js b/vite.config.js index 970be3b6..63a37ec9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,9 @@ /** * External dependencies */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import MagicString from 'magic-string'; @@ -30,7 +33,12 @@ export default defineConfig( { 'source-map-js': nodeModuleStub, }, }, - plugins: [ react(), wordPressExternals(), reactDevTools() ], + plugins: [ + react(), + wordPressExternals(), + reactDevTools(), + emitSupportedLocalesManifest(), + ], root: 'src', css: { preprocessorOptions: { @@ -199,6 +207,47 @@ function wordPressExternals() { }; } +/** + * Emit `supported-locales.json` to the build output. + * + * Scans `src/translations/` for `.json` files at build time and emits + * a single manifest listing every shipped locale tag. The native iOS and + * Android sides — and the JS-side resolver — all read this manifest so the + * "what do we actually ship?" answer has exactly one source of truth. + * + * @return {Object} Vite plugin configuration. + */ +function emitSupportedLocalesManifest() { + const translationsDir = path.resolve( + path.dirname( fileURLToPath( import.meta.url ) ), + 'src/translations' + ); + + function readSupportedLocales() { + if ( ! fs.existsSync( translationsDir ) ) { + return []; + } + return fs + .readdirSync( translationsDir ) + .filter( ( f ) => f.endsWith( '.json' ) ) + .map( ( f ) => f.replace( /\.json$/, '' ) ) + .filter( ( name ) => name !== 'supported-locales' ) + .sort(); + } + + return { + name: 'emit-supported-locales', + apply: 'build', + generateBundle() { + this.emitFile( { + type: 'asset', + fileName: 'supported-locales.json', + source: JSON.stringify( readSupportedLocales() ), + } ); + }, + }; +} + /** * Inject React Developer Tools connection script during development. * Only active when running the dev server, not in production builds.