1import * as fs from 'fs-extra';
2import walkSync from 'klaw-sync';
3import * as path from 'path';
4
5import { ModuleConfiguration } from './ModuleConfiguration';
6
7type PreparedPrefixes = [nameWithExpoPrefix: string, nameWithoutExpoPrefix: string];
8
9/**
10 * prepares _Expo_ prefixes for specified name
11 * @param name module name, e.g. JS package name
12 * @param prefix prefix to prepare with, defaults to _Expo_
13 * @returns tuple `[nameWithPrefix: string, nameWithoutPrefix: string]`
14 */
15const preparePrefixes = (name: string, prefix: string = 'Expo'): PreparedPrefixes =>
16  name.startsWith(prefix) ? [name, name.substr(prefix.length)] : [`${prefix}${name}`, name];
17
18const asyncForEach = async <T>(
19  array: T[],
20  callback: (value: T, index: number, array: T[]) => Promise<void>
21) => {
22  for (let index = 0; index < array.length; index++) {
23    await callback(array[index], index, array);
24  }
25};
26
27/**
28 * Removes specified files. If one file doesn't exist already, skips it
29 * @param directoryPath directory containing files to remove
30 * @param filenames array of filenames to remove
31 */
32async function removeFiles(directoryPath: string, filenames: string[]) {
33  await Promise.all(filenames.map((filename) => fs.remove(path.resolve(directoryPath, filename))));
34}
35
36/**
37 * Renames files names
38 * @param directoryPath - directory that holds files to be renamed
39 * @param extensions - array of extensions for files that would be renamed, must be provided with leading dot or empty for no extension, e.g. ['.html', '']
40 * @param renamings - array of filenames and their replacers
41 */
42const renameFilesWithExtensions = async (
43  directoryPath: string,
44  extensions: string[],
45  renamings: { from: string; to: string }[]
46) => {
47  await asyncForEach(
48    renamings,
49    async ({ from, to }) =>
50      await asyncForEach(extensions, async (extension) => {
51        const fromFilename = `${from}${extension}`;
52        if (!fs.existsSync(path.join(directoryPath, fromFilename))) {
53          return;
54        }
55        const toFilename = `${to}${extension}`;
56        await fs.rename(
57          path.join(directoryPath, fromFilename),
58          path.join(directoryPath, toFilename)
59        );
60      })
61  );
62};
63
64/**
65 * Enters each file recursively in provided dir and replaces content by invoking provided callback function
66 * @param directoryPath - root directory
67 * @param replaceFunction - function that converts current content into something different
68 */
69const replaceContents = async (
70  directoryPath: string,
71  replaceFunction: (contentOfSingleFile: string) => string
72) => {
73  await Promise.all(
74    walkSync(directoryPath, { nodir: true }).map((file) =>
75      replaceContent(file.path, replaceFunction)
76    )
77  );
78};
79
80/**
81 * Replaces content in file. Does nothing if the file doesn't exist
82 * @param filePath - provided file
83 * @param replaceFunction - function that converts current content into something different
84 */
85const replaceContent = async (
86  filePath: string,
87  replaceFunction: (contentOfSingleFile: string) => string
88) => {
89  if (!fs.existsSync(filePath)) {
90    return;
91  }
92
93  const content = await fs.readFile(filePath, 'utf8');
94  const newContent = replaceFunction(content);
95  if (newContent !== content) {
96    await fs.writeFile(filePath, newContent);
97  }
98};
99
100/**
101 * Removes all empty subdirs up to and including dirPath
102 * Recursively enters all subdirs and removes them if one is empty or cantained only empty subdirs
103 * @param dirPath - directory path that is being inspected
104 * @returns whether the given base directory and any empty subdirectories were deleted or not
105 */
106const removeUponEmptyOrOnlyEmptySubdirs = async (dirPath: string): Promise<boolean> => {
107  const contents = await fs.readdir(dirPath);
108  const results = await Promise.all(
109    contents.map(async (file) => {
110      const filePath = path.join(dirPath, file);
111      const fileStats = await fs.lstat(filePath);
112      return fileStats.isDirectory() && (await removeUponEmptyOrOnlyEmptySubdirs(filePath));
113    })
114  );
115  const isRemovable = results.reduce((acc, current) => acc && current, true);
116  if (isRemovable) {
117    await fs.remove(dirPath);
118  }
119  return isRemovable;
120};
121
122/**
123 * Prepares iOS part, mainly by renaming all files and some template word in files
124 * Versioning is done automatically based on package.json from JS/TS part
125 * @param modulePath - module directory
126 * @param configuration - naming configuration
127 */
128async function configureIOS(
129  modulePath: string,
130  { podName, jsPackageName, viewManager }: ModuleConfiguration
131) {
132  const iosPath = path.join(modulePath, 'ios');
133
134  // remove ViewManager from template
135  if (!viewManager) {
136    await removeFiles(path.join(iosPath, 'EXModuleTemplate'), [
137      `EXModuleTemplateView.h`,
138      `EXModuleTemplateView.m`,
139      `EXModuleTemplateViewManager.h`,
140      `EXModuleTemplateViewManager.m`,
141    ]);
142  }
143
144  await renameFilesWithExtensions(
145    path.join(iosPath, 'EXModuleTemplate'),
146    ['.h', '.m'],
147    [
148      { from: 'EXModuleTemplateModule', to: `${podName}Module` },
149      {
150        from: 'EXModuleTemplateView',
151        to: `${podName}View`,
152      },
153      {
154        from: 'EXModuleTemplateViewManager',
155        to: `${podName}ViewManager`,
156      },
157    ]
158  );
159  await renameFilesWithExtensions(
160    iosPath,
161    ['', '.podspec'],
162    [{ from: 'EXModuleTemplate', to: `${podName}` }]
163  );
164  await replaceContents(iosPath, (singleFileContent) =>
165    singleFileContent
166      .replace(/EXModuleTemplate/g, podName)
167      .replace(/ExpoModuleTemplate/g, jsPackageName)
168  );
169}
170
171/**
172 * Gets path to Android source base dir: android/src/main/[java|kotlin]
173 * Defaults to Java path if both exist
174 * @param androidPath path do module android/ directory
175 * @param flavor package flavor e.g main, test. Defaults to main
176 * @returns path to flavor source base directory
177 */
178function findAndroidSourceDir(androidPath: string, flavor: string = 'main'): string {
179  const androidSrcPathBase = path.join(androidPath, 'src', flavor);
180
181  const javaExists = fs.pathExistsSync(path.join(androidSrcPathBase, 'java'));
182  const kotlinExists = fs.pathExistsSync(path.join(androidSrcPathBase, 'kotlin'));
183
184  if (!javaExists && !kotlinExists) {
185    throw new Error(
186      `Invalid template. Android source directory not found: ${androidSrcPathBase}/[java|kotlin]`
187    );
188  }
189
190  return path.join(androidSrcPathBase, javaExists ? 'java' : 'kotlin');
191}
192
193/**
194 * Finds java package name based on directory structure
195 * @param flavorSrcPath Path to source base directory: e.g. android/src/main/java
196 * @returns java package name
197 */
198function findTemplateAndroidPackage(flavorSrcPath: string) {
199  const srcFiles = walkSync(flavorSrcPath, {
200    filter: (item) => item.path.endsWith('.kt') || item.path.endsWith('.java'),
201    nodir: true,
202    traverseAll: true,
203  });
204
205  if (srcFiles.length === 0) {
206    throw new Error('No Android source files found in the template');
207  }
208
209  // srcFiles[0] will always be at the most top-level of the package structure
210  const packageDirNames = path.relative(flavorSrcPath, srcFiles[0].path).split('/').slice(0, -1);
211
212  if (packageDirNames.length === 0) {
213    throw new Error('Template Android sources must be within a package.');
214  }
215
216  return packageDirNames.join('.');
217}
218
219/**
220 * Prepares Android part, mainly by renaming all files and template words in files
221 * Sets all versions in Gradle to 1.0.0
222 * @param modulePath - module directory
223 * @param configuration - naming configuration
224 */
225async function configureAndroid(
226  modulePath: string,
227  { javaPackage, jsPackageName, viewManager }: ModuleConfiguration
228) {
229  const androidPath = path.join(modulePath, 'android');
230  const [, moduleName] = preparePrefixes(jsPackageName, 'Expo');
231
232  const androidSrcPath = findAndroidSourceDir(androidPath);
233  const templateJavaPackage = findTemplateAndroidPackage(androidSrcPath);
234
235  const sourceFilesPath = path.join(androidSrcPath, ...templateJavaPackage.split('.'));
236  const destinationFilesPath = path.join(androidSrcPath, ...javaPackage.split('.'));
237
238  // remove ViewManager from template
239  if (!viewManager) {
240    removeFiles(sourceFilesPath, [`ModuleTemplateView.kt`, `ModuleTemplateViewManager.kt`]);
241
242    replaceContent(path.join(sourceFilesPath, 'ModuleTemplatePackage.kt'), (packageContent) =>
243      packageContent
244        .replace(/(^\s+)+(^.*?){1}createViewManagers[\s\W\w]+?\}/m, '')
245        .replace(/^.*ViewManager$/, '')
246    );
247  }
248
249  await fs.mkdirp(destinationFilesPath);
250  await fs.copy(sourceFilesPath, destinationFilesPath);
251
252  // Remove leaf directory content
253  await fs.remove(sourceFilesPath);
254  // Cleanup all empty subdirs up to template package root dir
255  await removeUponEmptyOrOnlyEmptySubdirs(
256    path.join(androidSrcPath, templateJavaPackage.split('.')[0])
257  );
258
259  // prepare tests
260  if (fs.existsSync(path.resolve(androidPath, 'src', 'test'))) {
261    const androidTestPath = findAndroidSourceDir(androidPath, 'test');
262    const templateTestPackage = findTemplateAndroidPackage(androidTestPath);
263    const testSourcePath = path.join(androidTestPath, ...templateTestPackage.split('.'));
264    const testDestinationPath = path.join(androidTestPath, ...javaPackage.split('.'));
265
266    await fs.mkdirp(testDestinationPath);
267    await fs.copy(testSourcePath, testDestinationPath);
268    await fs.remove(testSourcePath);
269    await removeUponEmptyOrOnlyEmptySubdirs(
270      path.join(androidTestPath, templateTestPackage.split('.')[0])
271    );
272
273    await replaceContents(testDestinationPath, (singleFileContent) =>
274      singleFileContent.replace(new RegExp(templateTestPackage, 'g'), javaPackage)
275    );
276
277    await renameFilesWithExtensions(
278      testDestinationPath,
279      ['.kt', '.java'],
280      [{ from: 'ModuleTemplateModuleTest', to: `${moduleName}ModuleTest` }]
281    );
282  }
283
284  // Replace contents of destination files
285  await replaceContents(androidPath, (singleFileContent) =>
286    singleFileContent
287      .replace(new RegExp(templateJavaPackage, 'g'), javaPackage)
288      .replace(/ModuleTemplate/g, moduleName)
289      .replace(/ExpoModuleTemplate/g, jsPackageName)
290  );
291  await replaceContent(path.join(androidPath, 'build.gradle'), (gradleContent) =>
292    gradleContent
293      .replace(/\bversion = ['"][\w.-]+['"]/, "version = '1.0.0'")
294      .replace(/versionCode \d+/, 'versionCode 1')
295      .replace(/versionName ['"][\w.-]+['"]/, "versionName '1.0.0'")
296  );
297  await renameFilesWithExtensions(
298    destinationFilesPath,
299    ['.kt', '.java'],
300    [
301      { from: 'ModuleTemplateModule', to: `${moduleName}Module` },
302      { from: 'ModuleTemplatePackage', to: `${moduleName}Package` },
303      { from: 'ModuleTemplateView', to: `${moduleName}View` },
304      { from: 'ModuleTemplateViewManager', to: `${moduleName}ViewManager` },
305    ]
306  );
307}
308
309/**
310 * Prepares TS part.
311 * @param modulePath - module directory
312 * @param configuration - naming configuration
313 */
314async function configureTS(
315  modulePath: string,
316  { jsPackageName, viewManager }: ModuleConfiguration
317) {
318  const [moduleNameWithExpoPrefix, moduleName] = preparePrefixes(jsPackageName);
319
320  const tsPath = path.join(modulePath, 'src');
321
322  // remove View Manager from template
323  if (!viewManager) {
324    await removeFiles(path.join(tsPath), [
325      'ExpoModuleTemplateView.tsx',
326      'ExpoModuleTemplateNativeView.ts',
327      'ExpoModuleTemplateNativeView.web.tsx',
328    ]);
329    await replaceContent(path.join(tsPath, 'ModuleTemplate.ts'), (fileContent) =>
330      fileContent.replace(/(^\s+)+(^.*?){1}ExpoModuleTemplateView.*$/m, '')
331    );
332  }
333
334  await renameFilesWithExtensions(
335    path.join(tsPath, '__tests__'),
336    ['.ts'],
337    [{ from: 'ModuleTemplate-test', to: `${moduleName}-test` }]
338  );
339  await renameFilesWithExtensions(
340    tsPath,
341    ['.tsx', '.ts'],
342    [
343      { from: 'ExpoModuleTemplateView', to: `${moduleNameWithExpoPrefix}View` },
344      { from: 'ExpoModuleTemplateNativeView', to: `${moduleNameWithExpoPrefix}NativeView` },
345      { from: 'ExpoModuleTemplateNativeView.web', to: `${moduleNameWithExpoPrefix}NativeView.web` },
346      { from: 'ExpoModuleTemplate', to: moduleNameWithExpoPrefix },
347      { from: 'ExpoModuleTemplate.web', to: `${moduleNameWithExpoPrefix}.web` },
348      { from: 'ModuleTemplate', to: moduleName },
349      { from: 'ModuleTemplate.types', to: `${moduleName}.types` },
350    ]
351  );
352
353  await replaceContents(tsPath, (singleFileContent) =>
354    singleFileContent
355      .replace(/ExpoModuleTemplate/g, moduleNameWithExpoPrefix)
356      .replace(/ModuleTemplate/g, moduleName)
357  );
358}
359
360/**
361 * Prepares files for npm (package.json and README.md).
362 * @param modulePath - module directory
363 * @param configuration - naming configuration
364 */
365async function configureNPM(
366  modulePath: string,
367  { npmModuleName, podName, jsPackageName }: ModuleConfiguration
368) {
369  const [, moduleName] = preparePrefixes(jsPackageName);
370
371  await replaceContent(path.join(modulePath, 'package.json'), (singleFileContent) =>
372    singleFileContent
373      .replace(/expo-module-template/g, npmModuleName)
374      .replace(/"version": "[\w.-]+"/, '"version": "1.0.0"')
375      .replace(/ExpoModuleTemplate/g, jsPackageName)
376      .replace(/ModuleTemplate/g, moduleName)
377  );
378  await replaceContent(path.join(modulePath, 'README.md'), (readmeContent) =>
379    readmeContent
380      .replace(/expo-module-template/g, npmModuleName)
381      .replace(/ExpoModuleTemplate/g, jsPackageName)
382      .replace(/EXModuleTemplate/g, podName)
383  );
384}
385
386/**
387 * Configures TS, Android and iOS parts of generated module mostly by applying provided renamings.
388 * @param modulePath - module directory
389 * @param configuration - naming configuration
390 */
391export default async function configureModule(
392  newModulePath: string,
393  configuration: ModuleConfiguration
394) {
395  await configureNPM(newModulePath, configuration);
396  await configureTS(newModulePath, configuration);
397  await configureAndroid(newModulePath, configuration);
398  await configureIOS(newModulePath, configuration);
399}
400