Skip to content

Commit 59235ae

Browse files
committed
feat: progressive language fallback
1 parent 4891717 commit 59235ae

File tree

4 files changed

+78
-31
lines changed

4 files changed

+78
-31
lines changed

core/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,13 @@ Tolgee.init {
174174
}
175175
```
176176

177-
When a user's locale (e.g., "zh-CN") is not available in your project:
177+
When a user's locale (e.g., "zh-Hans-CN") is not available in your project:
178178
1. Tolgee first tries to find the exact locale match
179-
2. If not found, tries the base language (e.g., "zh-CN" → "zh")
180-
3. If still not found, uses the default language (e.g., "en")
179+
2. If not found, tries intermediate variations (e.g., "zh-Hans-CN" → "zh-Hans")
180+
3. Then tries the base language (e.g., "zh-Hans" → "zh")
181+
4. If still not found, uses the default language (e.g., "en")
182+
183+
This progressive fallback follows BCP 47 locale tag structure.
181184

182185
#### Preloading Translations
183186

core/src/commonMain/kotlin/io/tolgee/Tolgee.kt

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,61 @@ open class Tolgee(
159159
return changeListeners.remove(listener)
160160
}
161161

162+
/**
163+
* Generates progressive fallback variations for a locale by removing components
164+
* from right to left following BCP 47 structure (language-script-region-variant).
165+
*
166+
* Examples:
167+
* - "zh-Hans-CN" → ["zh-Hans-CN", "zh-Hans", "zh"]
168+
* - "en-US" → ["en-US", "en"]
169+
* - "sr-Cyrl" → ["sr-Cyrl", "sr"]
170+
* - "en" → ["en"]
171+
*
172+
* @param locale The locale to generate fallbacks for
173+
* @return List of locales in fallback order (most specific to least specific)
174+
*/
175+
private fun generateLocaleFallbacks(locale: Locale): List<Locale> {
176+
val localeTag = locale.toTag("-")
177+
val components = localeTag.split("-")
178+
179+
val fallbacks = mutableListOf<Locale>()
180+
181+
// Start with the full locale
182+
fallbacks.add(locale)
183+
184+
// Generate intermediate variations by removing components from right to left
185+
for (i in components.size - 1 downTo 2) {
186+
val fallbackTag = components.subList(0, i).joinToString("-")
187+
fallbacks.add(forLocaleTag(fallbackTag))
188+
}
189+
190+
// Add base language if not already included (when components.size > 1)
191+
if (components.size > 1) {
192+
fallbacks.add(forLocaleTag(components[0]))
193+
}
194+
195+
return fallbacks
196+
}
197+
162198
/**
163199
* Resolves the most appropriate available locale from the given locale.
164200
*
165201
* Resolution strategy:
166-
* 1. If the exact locale is available, return it (e.g., "en-US" → "en-US")
167-
* 2. Otherwise, try to find an exact match for the base language (e.g., "en-US" → "en")
168-
* 3. If still not found, use the default language if configured
202+
* 1. Try exact locale match (e.g., "zh-Hans-CN" → "zh-Hans-CN")
203+
* 2. Try intermediate variations by progressively removing components:
204+
* - "zh-Hans-CN" → "zh-Hans"
205+
* - "zh-Hans" → "zh"
206+
* 3. Use the default language if configured
207+
*
208+
* The fallback process follows BCP 47 locale tag structure, removing
209+
* rightmost components (variant, region, script) one at a time until
210+
* a match is found.
211+
*
212+
* Examples:
213+
* - "zh-Hans-CN" → "zh-Hans-CN" → "zh-Hans" → "zh" → default
214+
* - "en-Latn-US" → "en-Latn-US" → "en-Latn" → "en" → default
215+
* - "sr-Cyrl" → "sr-Cyrl" → "sr" → default
216+
* - "en-US" → "en-US" → "en" → default
169217
*
170218
* Available locales are determined from:
171219
* 1. `config.availableLocales` (if manually specified)
@@ -188,27 +236,17 @@ open class Tolgee(
188236
return locale
189237
}
190238

191-
// First, check if exact locale is available
192-
if (locale in availableLocales) {
193-
return locale
194-
}
195-
196-
// Fallback: Look for exact base language match only
197-
// Find exact match for base language (e.g., "en" without region/script)
198-
val baseLanguageMatch = availableLocales.firstOrNull { availableLocale ->
199-
// Match only if:
200-
// 1. The language matches
201-
// 2. It's a base language (no region/script), checked by comparing tag to language
202-
availableLocale.language == locale.language &&
203-
availableLocale.toTag("-") == availableLocale.language
204-
}
239+
// Generate progressive fallback variations
240+
val fallbackCandidates = generateLocaleFallbacks(locale)
205241

206-
if (baseLanguageMatch != null) {
207-
return baseLanguageMatch
242+
// Try each fallback candidate in order
243+
for (candidate in fallbackCandidates) {
244+
if (candidate in availableLocales) {
245+
return candidate
246+
}
208247
}
209248

210-
// Final fallback: Use default language if configured, otherwise null
211-
// We assume the default language is available
249+
// Final fallback: Use default language if configured
212250
return config.defaultLanguage
213251
}
214252

@@ -496,10 +534,13 @@ open class Tolgee(
496534
* Instead, it will use the provided list of locales. Can be used to save on network requests.
497535
*
498536
* This list is used when determining the fallback language for translations.
499-
* For example, if the requested language doesn't exist, but it's non-regional variant
500-
* exists (`en-US` doesn't exist, but `en` does),we can use that instead. If we don't
501-
* have a list of available locales and manifest fetching fails, the fallback mechanism
502-
* will be disabled and only exactly matching locale will be used.
537+
* The SDK performs progressive fallback through intermediate locale variations:
538+
* - If "zh-Hans-CN" doesn't exist, tries "zh-Hans"
539+
* - If "zh-Hans" doesn't exist, tries "zh"
540+
* - Finally uses the default language if configured
541+
*
542+
* If we don't have a list of available locales and manifest fetching fails, the fallback
543+
* mechanism will be disabled and only exactly matching locale will be used.
503544
*/
504545
var availableLocales: List<Locale>? = null
505546

core/src/commonMain/kotlin/io/tolgee/api/TolgeeApi.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ internal data object TolgeeApi {
105105
*
106106
* This method is used when manifest fetching fails, no further attempts to fetch manifest
107107
* are made during the application's lifecycle. By setting the `locales` to `null`, it disables
108-
* the locale fallback mechanism, where translations fallback from a regional variant to the base
109-
* language (e.g., "en-US" to "en").
108+
* the locale fallback mechanism, where translations progressively fallback through intermediate
109+
* locale variations to the base language (e.g., "zh-Hans-CN" → "zh-Hans" → "zh").
110110
*
111111
* @return A [TolgeeManifest] instance with `locales` set to `null`, effectively disabling
112112
* all locale fallback logic.

core/src/commonMain/kotlin/io/tolgee/model/TolgeeManifest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import kotlinx.serialization.Serializable
1010
* This metadata is fetched from the CDN and contains information about
1111
* which locales are available for translation fallback logic.
1212
*
13-
* @property locales List of locale tags (e.g., ["en", "en-US", "de", "fr-FR"])
13+
* The fallback logic progressively tries intermediate locale variations before
14+
* falling back to the base language (e.g., "zh-Hans-CN" → "zh-Hans" → "zh").
15+
*
16+
* @property locales List of locale tags (e.g., ["en", "en-US", "zh", "zh-Hans", "zh-Hans-CN"])
1417
* that are available in this Tolgee project. If null, the
1518
* fallback mechanism is disabled and only exact locale matches
1619
* will be used.

0 commit comments

Comments
 (0)