1import Debug from 'debug';
2
3import {
4  ConfigPlugin,
5  ExportedConfig,
6  ExportedConfigWithProps,
7  ModPlatform,
8} from '../Plugin.types';
9import { BaseModOptions, withBaseMod } from './withMod';
10
11const debug = Debug('expo:config-plugins:base-mods');
12
13export type ForwardedBaseModOptions = Partial<
14  Pick<BaseModOptions, 'saveToInternal' | 'skipEmptyMod'>
15>;
16
17export type BaseModProviderMethods<
18  ModType,
19  Props extends ForwardedBaseModOptions = ForwardedBaseModOptions
20> = {
21  getFilePath: (config: ExportedConfigWithProps<ModType>, props: Props) => Promise<string> | string;
22  read: (
23    filePath: string,
24    config: ExportedConfigWithProps<ModType>,
25    props: Props
26  ) => Promise<ModType> | ModType;
27  write: (
28    filePath: string,
29    config: ExportedConfigWithProps<ModType>,
30    props: Props
31  ) => Promise<void> | void;
32  /**
33   * If the mod supports introspection, and avoids making any filesystem modifications during compilation.
34   * By enabling, this mod, and all of its descendants will be run in introspection mode.
35   * This should only be used for static files like JSON or XML, and not for application files that require regexes,
36   * or complex static files that require other files to be generated like Xcode `.pbxproj`.
37   */
38  isIntrospective?: boolean;
39};
40
41export type CreateBaseModProps<
42  ModType,
43  Props extends ForwardedBaseModOptions = ForwardedBaseModOptions
44> = {
45  methodName: string;
46  platform: ModPlatform;
47  modName: string;
48} & BaseModProviderMethods<ModType, Props>;
49
50export function createBaseMod<
51  ModType,
52  Props extends ForwardedBaseModOptions = ForwardedBaseModOptions
53>({
54  methodName,
55  platform,
56  modName,
57  getFilePath,
58  read,
59  write,
60  isIntrospective,
61}: CreateBaseModProps<ModType, Props>): ConfigPlugin<Props | void> {
62  const withUnknown: ConfigPlugin<Props | void> = (config, _props) => {
63    const props = _props || ({} as Props);
64    return withBaseMod<ModType>(config, {
65      platform,
66      mod: modName,
67      skipEmptyMod: props.skipEmptyMod ?? true,
68      saveToInternal: props.saveToInternal ?? false,
69      isProvider: true,
70      isIntrospective,
71      async action({ modRequest: { nextMod, ...modRequest }, ...config }) {
72        try {
73          let results: ExportedConfigWithProps<ModType> = {
74            ...config,
75            modRequest,
76          };
77
78          const filePath = await getFilePath(results, props);
79          debug(`mods.${platform}.${modName}: file path: ${filePath || '[skipped]'}`);
80          const modResults = await read(filePath, results, props);
81
82          results = await nextMod!({
83            ...results,
84            modResults,
85            modRequest,
86          });
87
88          assertModResults(results, modRequest.platform, modRequest.modName);
89
90          await write(filePath, results, props);
91          return results;
92        } catch (error: any) {
93          error.message = `[${platform}.${modName}]: ${methodName}: ${error.message}`;
94          throw error;
95        }
96      },
97    });
98  };
99
100  if (methodName) {
101    Object.defineProperty(withUnknown, 'name', {
102      value: methodName,
103    });
104  }
105
106  return withUnknown;
107}
108
109export function assertModResults(results: any, platformName: string, modName: string) {
110  // If the results came from a mod, they'd be in the form of [config, data].
111  // Ensure the results are an array and omit the data since it should've been written by a data provider plugin.
112  const ensuredResults = results;
113
114  // Sanity check to help locate non compliant mods.
115  if (!ensuredResults || typeof ensuredResults !== 'object' || !ensuredResults?.mods) {
116    throw new Error(
117      `Mod \`mods.${platformName}.${modName}\` evaluated to an object that is not a valid project config. Instead got: ${JSON.stringify(
118        ensuredResults
119      )}`
120    );
121  }
122  return ensuredResults;
123}
124
125function upperFirst(name: string): string {
126  return name.charAt(0).toUpperCase() + name.slice(1);
127}
128
129export function createPlatformBaseMod<
130  ModType,
131  Props extends ForwardedBaseModOptions = ForwardedBaseModOptions
132>({ modName, ...props }: Omit<CreateBaseModProps<ModType, Props>, 'methodName'>) {
133  // Generate the function name to ensure it's uniform and also to improve stack traces.
134  const methodName = `with${upperFirst(props.platform)}${upperFirst(modName)}BaseMod`;
135  return createBaseMod<ModType, Props>({
136    methodName,
137    modName,
138    ...props,
139  });
140}
141
142/** A TS wrapper for creating provides */
143export function provider<ModType, Props extends ForwardedBaseModOptions = ForwardedBaseModOptions>(
144  props: BaseModProviderMethods<ModType, Props>
145) {
146  return props;
147}
148
149/** Plugin to create and append base mods from file providers */
150export function withGeneratedBaseMods<ModName extends string>(
151  config: ExportedConfig,
152  {
153    platform,
154    providers,
155    ...props
156  }: ForwardedBaseModOptions & {
157    /** Officially supports `'ios' | 'android'` (`ModPlatform`). Arbitrary strings are supported for adding out-of-tree platforms. */
158    platform: ModPlatform & string;
159    providers: Partial<Record<ModName, BaseModProviderMethods<any, any>>>;
160  }
161): ExportedConfig {
162  return Object.entries(providers).reduce((config, [modName, value]) => {
163    const baseMod = createPlatformBaseMod({ platform, modName, ...(value as any) });
164    return baseMod(config, props);
165  }, config);
166}
167