create-expo-cljs-app
Version:
Create a react native application with Expo and Shadow-CLJS!
301 lines (263 loc) • 10.1 kB
text/typescript
import chalk from 'chalk';
import glob from 'fast-glob';
import findUp from 'find-up';
import fs from 'fs-extra';
import { createRequire } from 'module';
import path from 'path';
import { requireAndResolveExpoModuleConfig } from './ExpoModuleConfig';
import {
GenerateOptions,
ModuleDescriptor,
PackageRevision,
ResolveOptions,
SearchOptions,
SearchResults,
} from './types';
// Names of the config files. From lowest to highest priority.
const EXPO_MODULE_CONFIG_FILENAMES = ['unimodule.json', 'expo-module.config.json'];
/**
* Path to the `package.json` of the closest project in the current working dir.
*/
const projectPackageJsonPath = findUp.sync('package.json', { cwd: process.cwd() }) as string;
// This won't happen in usual scenarios, but we need to unwrap the optional path :)
if (!projectPackageJsonPath) {
throw new Error(`Couldn't find "package.json" up from path "${process.cwd()}"`);
}
/**
* Custom `require` that resolves from the current working dir instead of this script path.
* **Requires Node v12.2.0**
*/
const projectRequire = createRequire(projectPackageJsonPath);
/**
* Resolves autolinking search paths. If none is provided, it accumulates all node_modules when
* going up through the path components. This makes workspaces work out-of-the-box without any configs.
*/
export async function resolveSearchPathsAsync(
searchPaths: string[] | null,
cwd: string
): Promise<string[]> {
return searchPaths && searchPaths.length > 0
? searchPaths.map((searchPath) => path.resolve(cwd, searchPath))
: await findDefaultPathsAsync(cwd);
}
/**
* Looks up for workspace's `node_modules` paths.
*/
export async function findDefaultPathsAsync(cwd: string): Promise<string[]> {
const paths = [];
let dir = cwd;
let pkgJsonPath: string | undefined;
while ((pkgJsonPath = await findUp('package.json', { cwd: dir }))) {
dir = path.dirname(path.dirname(pkgJsonPath));
paths.push(path.join(pkgJsonPath, '..', 'node_modules'));
}
return paths;
}
/**
* Searches for modules to link based on given config.
*/
export async function findModulesAsync(providedOptions: SearchOptions): Promise<SearchResults> {
const options = await mergeLinkingOptionsAsync(providedOptions);
const results: SearchResults = {};
for (const searchPath of options.searchPaths) {
const bracedFilenames = '{' + EXPO_MODULE_CONFIG_FILENAMES.join(',') + '}';
const paths = await glob([`*/${bracedFilenames}`, `@*/*/${bracedFilenames}`], {
cwd: searchPath,
});
// If the package has multiple configs (e.g. `unimodule.json` and `expo-module.config.json` during the transition time)
// then we want to give `expo-module.config.json` the priority.
const uniqueConfigPaths: string[] = Object.values(
paths.reduce<Record<string, string>>((acc, configPath) => {
const dirname = path.dirname(configPath);
if (!acc[dirname] || configPriority(configPath) > configPriority(acc[dirname])) {
acc[dirname] = configPath;
}
return acc;
}, {})
);
for (const packageConfigPath of uniqueConfigPaths) {
const packagePath = await fs.realpath(path.join(searchPath, path.dirname(packageConfigPath)));
const expoModuleConfig = requireAndResolveExpoModuleConfig(
path.join(packagePath, path.basename(packageConfigPath))
);
const { name, version } = require(path.join(packagePath, 'package.json'));
if (options.exclude?.includes(name) || !expoModuleConfig.supportsPlatform(options.platform)) {
continue;
}
const currentRevision: PackageRevision = {
path: packagePath,
version,
};
if (!results[name]) {
// The revision that was found first will be the main one.
// An array of duplicates and the config are needed only here.
results[name] = {
...currentRevision,
config: expoModuleConfig,
duplicates: [],
};
} else if (
results[name].path !== packagePath &&
results[name].duplicates?.every(({ path }) => path !== packagePath)
) {
results[name].duplicates?.push(currentRevision);
}
}
}
// It doesn't make much sense to strip modules if there is only one search path.
// Workspace root usually doesn't specify all its dependencies (see Expo Go),
// so in this case we should link everything.
if (options.searchPaths.length <= 1) {
return results;
}
return filterToProjectDependencies(results, providedOptions);
}
/**
* Filters out packages that are not the dependencies of the project.
*/
function filterToProjectDependencies(
results: SearchResults,
options: Pick<SearchOptions, 'silent'> = {}
) {
const filteredResults: SearchResults = {};
const visitedPackages = new Set<string>();
// Helper for traversing the dependency hierarchy.
function visitPackage(packageJsonPath: string) {
const packageJson = require(packageJsonPath);
// Prevent getting into the recursive loop.
if (visitedPackages.has(packageJson.name)) {
return;
}
visitedPackages.add(packageJson.name);
// Iterate over the dependencies to find transitive modules.
for (const dependencyName in packageJson.dependencies) {
const dependencyResult = results[dependencyName];
if (!filteredResults[dependencyName]) {
let dependencyPackageJsonPath: string;
if (dependencyResult) {
filteredResults[dependencyName] = dependencyResult;
dependencyPackageJsonPath = path.join(dependencyResult.path, 'package.json');
} else {
try {
dependencyPackageJsonPath = projectRequire.resolve(`${dependencyName}/package.json`);
} catch (error: any) {
// Some packages don't include package.json in its `exports` field,
// but none of our packages do that, so it seems fine to just ignore that type of error.
// Related issue: https://github.com/react-native-community/cli/issues/1168
if (!options.silent && error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
console.warn(
chalk.yellow(`⚠️ Cannot resolve the path to "${dependencyName}" package.`)
);
}
continue;
}
}
// Visit the dependency package.
visitPackage(dependencyPackageJsonPath);
}
}
}
// Visit project's package.
visitPackage(projectPackageJsonPath);
return filteredResults;
}
/**
* Merges autolinking options from different sources (the later the higher priority)
* - options defined in package.json's `expoModules` field
* - platform-specific options from the above (e.g. `expoModules.ios`)
* - options provided to the CLI command
*/
export async function mergeLinkingOptionsAsync<OptionsType extends SearchOptions>(
providedOptions: OptionsType
): Promise<OptionsType> {
const packageJson = require(projectPackageJsonPath);
const baseOptions = packageJson.expo?.autolinking;
const platformOptions = providedOptions.platform && baseOptions?.[providedOptions.platform];
const finalOptions = Object.assign(
{},
baseOptions,
platformOptions,
providedOptions
) as OptionsType;
// Makes provided paths absolute or falls back to default paths if none was provided.
finalOptions.searchPaths = await resolveSearchPathsAsync(finalOptions.searchPaths, process.cwd());
return finalOptions;
}
/**
* Verifies the search results by checking whether there are no duplicates.
*/
export function verifySearchResults(searchResults: SearchResults): number {
const cwd = process.cwd();
const relativePath: (pkg: PackageRevision) => string = (pkg) => path.relative(cwd, pkg.path);
let counter = 0;
for (const moduleName in searchResults) {
const revision = searchResults[moduleName];
if (revision.duplicates?.length) {
console.warn(`⚠️ Found multiple revisions of ${chalk.green(moduleName)}`);
console.log(` - ${chalk.magenta(relativePath(revision))} (${chalk.cyan(revision.version)})`);
for (const duplicate of revision.duplicates) {
console.log(` - ${chalk.gray(relativePath(duplicate))} (${chalk.gray(duplicate.version)})`);
}
counter++;
}
}
if (counter > 0) {
console.warn(
'⚠️ Please get rid of multiple revisions as it may introduce some side effects or compatibility issues'
);
}
return counter;
}
/**
* Resolves search results to a list of platform-specific configuration.
*/
export async function resolveModulesAsync(
searchResults: SearchResults,
options: ResolveOptions
): Promise<ModuleDescriptor[]> {
const platformLinking = require(`./platforms/${options.platform}`);
return (
await Promise.all(
Object.entries(searchResults).map(async ([packageName, revision]) => {
const resolvedModule = await platformLinking.resolveModuleAsync(
packageName,
revision,
options
);
return resolvedModule
? {
packageName,
packageVersion: revision.version,
...resolvedModule,
}
: null;
})
)
)
.filter(Boolean)
.sort((a, b) => a.packageName.localeCompare(b.packageName));
}
/**
* Generates a source file listing all packages to link.
* Right know it works only for Android platform.
*/
export async function generatePackageListAsync(
modules: ModuleDescriptor[],
options: GenerateOptions
) {
try {
const platformLinking = require(`./platforms/${options.platform}`);
await platformLinking.generatePackageListAsync(modules, options.target, options.namespace);
} catch (e) {
console.error(
chalk.red(`Generating package list is not available for platform: ${options.platform}`)
);
throw e;
}
}
/**
* Returns the priority of the config at given path. Higher number means higher priority.
*/
function configPriority(fullpath: string): number {
return EXPO_MODULE_CONFIG_FILENAMES.indexOf(path.basename(fullpath));
}