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