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