1import { ExpoConfig } from '@expo/config-types'; 2import JsonFile from '@expo/json-file'; 3import fs from 'fs'; 4import { join, relative } from 'path'; 5import { XcodeProject } from 'xcode'; 6 7import { addResourceFileToGroup, ensureGroupRecursively, getProjectName } from './utils/Xcodeproj'; 8import { ConfigPlugin } from '../Plugin.types'; 9import { withXcodeProject } from '../plugins/ios-plugins'; 10import { addWarningIOS } from '../utils/warnings'; 11 12type LocaleJson = Record<string, string>; 13type ResolvedLocalesJson = Record<string, LocaleJson>; 14type ExpoConfigLocales = NonNullable<ExpoConfig['locales']>; 15 16export const withLocales: ConfigPlugin = (config) => { 17 return withXcodeProject(config, async (config) => { 18 config.modResults = await setLocalesAsync(config, { 19 projectRoot: config.modRequest.projectRoot, 20 project: config.modResults, 21 }); 22 return config; 23 }); 24}; 25 26export function getLocales( 27 config: Pick<ExpoConfig, 'locales'> 28): Record<string, string | LocaleJson> | null { 29 return config.locales ?? null; 30} 31 32export async function setLocalesAsync( 33 config: Pick<ExpoConfig, 'locales'>, 34 { projectRoot, project }: { projectRoot: string; project: XcodeProject } 35): Promise<XcodeProject> { 36 const locales = getLocales(config); 37 if (!locales) { 38 return project; 39 } 40 // possibly validate CFBundleAllowMixedLocalizations is enabled 41 const localesMap = await getResolvedLocalesAsync(projectRoot, locales); 42 43 const projectName = getProjectName(projectRoot); 44 const supportingDirectory = join(projectRoot, 'ios', projectName, 'Supporting'); 45 46 // TODO: Should we delete all before running? Revisit after we land on a lock file. 47 const stringName = 'InfoPlist.strings'; 48 49 for (const [lang, localizationObj] of Object.entries(localesMap)) { 50 const dir = join(supportingDirectory, `${lang}.lproj`); 51 // await fs.ensureDir(dir); 52 await fs.promises.mkdir(dir, { recursive: true }); 53 54 const strings = join(dir, stringName); 55 const buffer = []; 56 for (const [plistKey, localVersion] of Object.entries(localizationObj)) { 57 buffer.push(`${plistKey} = "${localVersion}";`); 58 } 59 // Write the file to the file system. 60 await fs.promises.writeFile(strings, buffer.join('\n')); 61 62 const groupName = `${projectName}/Supporting/${lang}.lproj`; 63 // deep find the correct folder 64 const group = ensureGroupRecursively(project, groupName); 65 66 // Ensure the file doesn't already exist 67 if (!group?.children.some(({ comment }) => comment === stringName)) { 68 // Only write the file if it doesn't already exist. 69 project = addResourceFileToGroup({ 70 filepath: relative(supportingDirectory, strings), 71 groupName, 72 project, 73 isBuildFile: true, 74 verbose: true, 75 }); 76 } 77 } 78 79 return project; 80} 81 82export async function getResolvedLocalesAsync( 83 projectRoot: string, 84 input: ExpoConfigLocales 85): Promise<ResolvedLocalesJson> { 86 const locales: ResolvedLocalesJson = {}; 87 for (const [lang, localeJsonPath] of Object.entries(input)) { 88 if (typeof localeJsonPath === 'string') { 89 try { 90 locales[lang] = await JsonFile.readAsync(join(projectRoot, localeJsonPath)); 91 } catch { 92 // Add a warning when a json file cannot be parsed. 93 addWarningIOS( 94 `locales.${lang}`, 95 `Failed to parse JSON of locale file for language: ${lang}`, 96 'https://docs.expo.dev/distribution/app-stores/#localizing-your-ios-app' 97 ); 98 } 99 } else { 100 // In the off chance that someone defined the locales json in the config, pass it directly to the object. 101 // We do this to make the types more elegant. 102 locales[lang] = localeJsonPath; 103 } 104 } 105 106 return locales; 107} 108