131 lines
9 KiB
TypeScript
131 lines
9 KiB
TypeScript
// Store language codes on both stores, and how one maps onto the other. Pure.
|
|
// App Store Connect locales (what fastlane deliver names its metadata folders after) and Google Play language codes
|
|
// (what fastlane supply names its folders after) differ for the same language: "tr" vs "tr-TR", "zh-Hans" vs "zh-CN",
|
|
// "he" vs "iw-IL". storebound keeps one `languages[]` entry per language with both codes, so an importer needs the pairs.
|
|
import type { Language } from './config';
|
|
|
|
// Sources, checked 2026-10-11 (re-check when a store announces new languages):
|
|
// - App Store: the 50 locale shortcodes in "Managing metadata in your app by using locale shortcodes" (App Store Connect
|
|
// API docs, developer.apple.com/documentation/appstoreconnectapi/managing-metadata-in-your-app-by-using-locale-shortcodes),
|
|
// the same 50 languages as App Store Connect Help > Reference > "App Store localizations"
|
|
// (developer.apple.com/help/app-store-connect/reference/app-store-localizations). These are the `locale` values of
|
|
// appInfoLocalizations / appStoreVersionLocalizations. Apple names or-IN "Oriya" there and "Odia" in the Help table.
|
|
// - Google Play: "View list of available languages" in Play Console Help "Translate and localize your app"
|
|
// (support.google.com/googleplay/android-developer/answer/9844778), 86 codes; the androidpublisher API's
|
|
// edits.listings `language` takes the same codes. NOT the "Supported languages" table
|
|
// (support.google.com/googleplay/android-developer/table/4419860): that one lists Play Console's own UI languages with
|
|
// other spellings (he, tr, ja, de) that are not listing codes.
|
|
|
|
/** App Store Connect locales (50). Bangla, Gujarati, Kannada, Malayalam, Marathi, Odia, Punjabi, Slovenian, Tamil, Telugu
|
|
* and Urdu were added in 2025. */
|
|
export const APPLE_LOCALES: readonly string[] = [
|
|
'ar-SA', 'bn-BD', 'ca', 'cs', 'da', 'de-DE', 'el', 'en-AU', 'en-CA', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fi', 'fr-CA',
|
|
'fr-FR', 'gu-IN', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'ja', 'kn-IN', 'ko', 'ml-IN', 'mr-IN', 'ms', 'nl-NL', 'no',
|
|
'or-IN', 'pa-IN', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl-SI', 'sv', 'ta-IN', 'te-IN', 'th', 'tr', 'uk', 'ur-PK',
|
|
'vi', 'zh-Hans', 'zh-Hant',
|
|
];
|
|
|
|
/** Google Play store listing languages (86; Play Console > Store listing > Manage translations, the API uses the same
|
|
* codes). Zulu ("zu", in older lists and fastlane's) is no longer in Google's list. Odia has no Play code. */
|
|
export const PLAY_LOCALES: readonly string[] = [
|
|
'af', 'am', 'ar', 'az-AZ', 'be', 'bg', 'bn-BD', 'ca', 'cs-CZ', 'da-DK', 'de-DE', 'el-GR', 'en-AU', 'en-CA', 'en-GB',
|
|
'en-IN', 'en-SG', 'en-US', 'en-ZA', 'es-419', 'es-ES', 'es-US', 'et', 'eu-ES', 'fa', 'fa-AE', 'fa-AF', 'fa-IR', 'fi-FI',
|
|
'fil', 'fr-CA', 'fr-FR', 'gl-ES', 'gu', 'hi-IN', 'hr', 'hu-HU', 'hy-AM', 'id', 'is-IS', 'it-IT', 'iw-IL', 'ja-JP',
|
|
'ka-GE', 'kk', 'km-KH', 'kn-IN', 'ko-KR', 'ky-KG', 'lo-LA', 'lt', 'lv', 'mk-MK', 'ml-IN', 'mn-MN', 'mr-IN', 'ms', 'ms-MY',
|
|
'my-MM', 'ne-NP', 'nl-NL', 'no-NO', 'pa', 'pl-PL', 'pt-BR', 'pt-PT', 'rm', 'ro', 'ru-RU', 'si-LK', 'sk', 'sl', 'sq',
|
|
'sr', 'sv-SE', 'sw', 'ta-IN', 'te-IN', 'th', 'tr-TR', 'uk', 'ur', 'vi', 'zh-CN', 'zh-HK', 'zh-TW',
|
|
];
|
|
|
|
/** App Store locale -> the Play code of the same language, where the two differ (the rest are spelled the same). */
|
|
export const APPLE_TO_PLAY: Readonly<Record<string, string>> = {
|
|
'ar-SA': 'ar', 'cs': 'cs-CZ', 'da': 'da-DK', 'el': 'el-GR', 'es-MX': 'es-419', 'fi': 'fi-FI', 'gu-IN': 'gu', 'he': 'iw-IL',
|
|
'hi': 'hi-IN', 'hu': 'hu-HU', 'it': 'it-IT', 'ja': 'ja-JP', 'ko': 'ko-KR', 'no': 'no-NO', 'pa-IN': 'pa', 'pl': 'pl-PL',
|
|
'ru': 'ru-RU', 'sl-SI': 'sl', 'sv': 'sv-SE', 'tr': 'tr-TR', 'ur-PK': 'ur', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW',
|
|
};
|
|
|
|
/** Play code -> App Store locale (the inverse of APPLE_TO_PLAY plus Play codes that share one App Store locale). */
|
|
export const PLAY_TO_APPLE: Readonly<Record<string, string>> = {
|
|
...Object.fromEntries(Object.entries(APPLE_TO_PLAY).map(([a, p]) => [p, a])),
|
|
'ms-MY': 'ms',
|
|
};
|
|
|
|
/** Spellings seen in fastlane folders and older tools that are not the store's own code. */
|
|
const APPLE_ALIASES: Readonly<Record<string, string>> = {
|
|
'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES', 'nl': 'nl-NL', 'ar': 'ar-SA', 'iw': 'he', 'he-IL': 'he',
|
|
'nb': 'no', 'nb-NO': 'no', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', 'ja-JP': 'ja', 'ko-KR': 'ko', 'it-IT': 'it',
|
|
'ru-RU': 'ru', 'tr-TR': 'tr', 'sv-SE': 'sv', 'da-DK': 'da', 'fi-FI': 'fi', 'no-NO': 'no', 'pl-PL': 'pl', 'cs-CZ': 'cs',
|
|
'el-GR': 'el', 'hu-HU': 'hu', 'hi-IN': 'hi', 'uk-UA': 'uk', 'th-TH': 'th', 'vi-VN': 'vi', 'id-ID': 'id', 'ms-MY': 'ms',
|
|
'ro-RO': 'ro', 'sk-SK': 'sk', 'hr-HR': 'hr', 'ca-ES': 'ca',
|
|
// the 2025 languages without their region, and Play's spellings of them
|
|
'bn': 'bn-BD', 'gu': 'gu-IN', 'kn': 'kn-IN', 'ml': 'ml-IN', 'mr': 'mr-IN', 'or': 'or-IN', 'pa': 'pa-IN', 'sl': 'sl-SI',
|
|
'ta': 'ta-IN', 'te': 'te-IN', 'ur': 'ur-PK',
|
|
};
|
|
const PLAY_ALIASES: Readonly<Record<string, string>> = {
|
|
'he-IL': 'iw-IL', 'he': 'iw-IL', 'iw': 'iw-IL', 'id-ID': 'id', 'in-ID': 'id', 'nb-NO': 'no-NO', 'nb': 'no-NO',
|
|
'fil-PH': 'fil', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-TW', 'en': 'en-US', 'de': 'de-DE', 'fr': 'fr-FR', 'es': 'es-ES',
|
|
'tr': 'tr-TR', 'ja': 'ja-JP', 'ko': 'ko-KR', 'it': 'it-IT', 'ru': 'ru-RU', 'pt': 'pt-PT', 'nl': 'nl-NL', 'pl': 'pl-PL',
|
|
// App Store spellings of languages Play writes differently
|
|
'ar-SA': 'ar', 'bn': 'bn-BD', 'gu-IN': 'gu', 'kn': 'kn-IN', 'ml': 'ml-IN', 'mr': 'mr-IN', 'pa-IN': 'pa', 'sl-SI': 'sl',
|
|
'ta': 'ta-IN', 'te': 'te-IN', 'ur-PK': 'ur', 'es-MX': 'es-419', 'cs': 'cs-CZ', 'da': 'da-DK', 'el': 'el-GR', 'fi': 'fi-FI',
|
|
'hi': 'hi-IN', 'hu': 'hu-HU', 'sv': 'sv-SE', 'no': 'no-NO',
|
|
};
|
|
|
|
export interface Normalized { code: string; known: boolean; alias?: string }
|
|
|
|
/** A fastlane deliver folder name -> App Store locale. Unknown codes are kept as they are (known: false). */
|
|
export function appleLocale(folder: string): Normalized {
|
|
if (APPLE_LOCALES.includes(folder)) return { code: folder, known: true };
|
|
const hit = APPLE_ALIASES[folder] ?? APPLE_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase());
|
|
return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false };
|
|
}
|
|
|
|
/** A fastlane supply folder name -> Play language code. Unknown codes are kept as they are (known: false). */
|
|
export function playLocale(folder: string): Normalized {
|
|
if (PLAY_LOCALES.includes(folder)) return { code: folder, known: true };
|
|
const hit = PLAY_ALIASES[folder] ?? PLAY_LOCALES.find(l => l.toLowerCase() === folder.toLowerCase());
|
|
return hit ? { code: hit, known: true, alias: folder } : { code: folder, known: false };
|
|
}
|
|
|
|
/** Play code of the same language as an App Store locale, if Play has it. */
|
|
export const appleToPlay = (apple: string): string | undefined =>
|
|
APPLE_TO_PLAY[apple] ?? (PLAY_LOCALES.includes(apple) ? apple : undefined);
|
|
|
|
/** App Store locale of the same language as a Play code, if the App Store has it. */
|
|
export const playToApple = (play: string): string | undefined =>
|
|
PLAY_TO_APPLE[play] ?? (APPLE_LOCALES.includes(play) ? play : undefined);
|
|
|
|
/**
|
|
* storebound `languages[]` from the App Store locales and Play codes a project has: one entry per language with both
|
|
* codes when both stores have it, else one store's code. Ids are the base language ("en", "tr", "zh"), or the whole
|
|
* code in lower case when the base is taken ("en-gb", "pt-pt"). en-US (else the first) comes first.
|
|
*/
|
|
export function pairLanguages(apple: string[], play: string[], primary?: string): Language[] {
|
|
const pairs: { apple?: string; play?: string }[] = [];
|
|
const playLeft = new Set(play);
|
|
for (const a of [...new Set(apple)]) {
|
|
const p = appleToPlay(a);
|
|
if (p && playLeft.has(p)) { pairs.push({ apple: a, play: p }); playLeft.delete(p); continue; }
|
|
// a Play code that maps back onto this locale (ms-MY -> ms)
|
|
const back = [...playLeft].find(x => playToApple(x) === a);
|
|
if (back) { pairs.push({ apple: a, play: back }); playLeft.delete(back); continue; }
|
|
pairs.push({ apple: a });
|
|
}
|
|
for (const p of playLeft) pairs.push({ play: p });
|
|
const tag = (x: { apple?: string; play?: string }) => x.apple ?? playToApple(x.play!) ?? x.play!;
|
|
const first = (x: { apple?: string; play?: string }) => (primary ? x.apple === primary || x.play === primary : x.apple === 'en-US' || x.play === 'en-US');
|
|
pairs.sort((x, y) => Number(first(y)) - Number(first(x)) || tag(x).localeCompare(tag(y)));
|
|
const used = new Set<string>();
|
|
return pairs.map(x => {
|
|
const t = tag(x);
|
|
let id = t.split('-')[0]!.toLowerCase();
|
|
if (used.has(id)) id = t.toLowerCase();
|
|
for (let n = 2; used.has(id); n++) id = `${t.toLowerCase()}-${n}`;
|
|
used.add(id);
|
|
return { id, ...(x.play ? { play: x.play } : {}), ...(x.apple ? { apple: x.apple } : {}) };
|
|
});
|
|
}
|
|
|
|
/** English name of a language code for headings ("tr-TR" -> "Turkish"), else the code. */
|
|
export function languageName(code: string): string {
|
|
try { return new Intl.DisplayNames(['en'], { type: 'language' }).of(code.replace(/^iw\b/, 'he')) ?? code; } catch { return code; }
|
|
}
|