Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/pages/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ If you want to create your own React Native library, scaffolding the project can
- [TypeScript](https://www.typescriptlang.org/) to ensure type-safe code and better DX
- Support for [Turbo Modules](https://reactnative.dev/docs/turbo-native-modules-introduction) & [Fabric](https://reactnative.dev/docs/fabric-native-components-introduction)
- Support for [Kotlin](https://kotlinlang.org/) on Android & [Swift](https://developer.apple.com/swift/) on iOS
- Support for C++ to write cross-platform native code
- [Expo](https://expo.io/) for libraries without native code and web support
- Example apps with [Community CLI](https://github.com/react-native-community/cli), [Expo](https://expo.dev/) or [React Native Test App](https://github.com/microsoft/react-native-test-app)
- Support for Web with [Expo Web](https://docs.expo.dev/workflow/web/) or [Vite](https://vitejs.dev/)
- [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/), [Lefthook](https://github.com/evilmartians/lefthook) and [Release It](https://github.com/release-it/release-it) pre-configured
- [`react-native-builder-bob`](./build.md) pre-configured to compile your files
- [GitHub Actions](https://github.com/features/actions) pre-configured to run tests and lint on the CI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ const PACKAGES_TO_REMOVE = [

const PACKAGES_TO_ADD_EXPO_WEB = {
'@expo/metro-runtime': '~5.0.4',
'react-dom': '19.1.0',
'react-native-web': '~0.21.1',
};

Expand Down Expand Up @@ -173,9 +172,11 @@ export default async function generateExampleApp({
'build:ios': `react-native build-ios --mode Debug`,
};

if (config.example === 'vanilla') {
if (config.example != null) {
Object.assign(scripts, SCRIPTS_TO_ADD);
} else if (config.example === 'test-app') {
}

if (config.example === 'test-app') {
// `react-native-test-app` doesn't bundle application by default in 'Release' mode and also `bundle` command doesn't create a directory.
// `mkdist` script should be removed after stable React Native major contains this fix: https://github.com/facebook/react-native/pull/45182.

Expand Down Expand Up @@ -276,8 +277,6 @@ export default async function generateExampleApp({
scripts.android = 'expo run:android';
scripts.ios = 'expo run:ios';

delete scripts.web;

await fs.writeFile(
path.join(directory, '.gitignore'),
dedent`
Expand All @@ -286,14 +285,22 @@ export default async function generateExampleApp({
ios/
`
);
} else {
Object.entries(PACKAGES_TO_ADD_EXPO_WEB).forEach(([name, version]) => {
dependencies[name] = bundledNativeModules[name] || version;
});
}

scripts.web = 'expo start --web';
const reactVersion = dependencies.react ?? devDependencies.react;

if (typeof reactVersion !== 'string') {
throw new Error("Couldn't find the package 'react' in the example app.");
}

Object.entries(PACKAGES_TO_ADD_EXPO_WEB).forEach(([name, version]) => {
dependencies[name] = bundledNativeModules[name] || version;
});

dependencies['react-dom'] = reactVersion;
scripts.web = 'expo start --web';
scripts['build:web'] = 'expo export --platform web';

const app = await fs.readJSON(path.join(directory, 'app.json'));

app.expo.name = `${config.project.name} Example`;
Expand Down
2 changes: 1 addition & 1 deletion packages/create-react-native-library/src/inform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function printNonLocalLibNextSteps(config: TemplateConfiguration) {
const platforms = {
ios: { name: 'iOS', color: 'cyan' },
android: { name: 'Android', color: 'green' },
...(config.example === 'expo'
...(config.example === 'expo' || config.tools.includes('vite')
? ({ web: { name: 'Web', color: 'blue' } } as const)
: null),
} as const;
Expand Down
30 changes: 20 additions & 10 deletions packages/create-react-native-library/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,15 +319,6 @@ export const prompt = create(['[name]'], {
title: choice.title,
value: choice.value,
description: choice.description,
skip: (): boolean => {
const answers = prompt.read();

if (answers.type === 'library') {
return choice.value !== 'expo';
}

return false;
},
})),
required: true,
skip: (): boolean => {
Expand All @@ -344,8 +335,27 @@ export const prompt = create(['[name]'], {
value: key,
title: tool.name,
description: tool.description,
skip: (): boolean => {
if ('condition' in tool && tool.condition) {
return !tool.condition({ example: prompt.read().example });
}

return false;
},
})),
default: Object.keys(AVAILABLE_TOOLS),
default: (): string[] => {
const answers = prompt.read();

return Object.entries(AVAILABLE_TOOLS)
.filter(([, tool]) => {
if ('condition' in tool && tool.condition) {
return tool.condition({ example: answers.example });
}

return true;
})
.map(([key]) => key);
},
required: true,
skip: (): boolean => {
const answers = prompt.read();
Expand Down
45 changes: 39 additions & 6 deletions packages/create-react-native-library/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const EXAMPLE_COMMON_FILES = path.resolve(
__dirname,
'../templates/example-common'
);
const EXAMPLE_BARE_FILES = path.resolve(__dirname, '../templates/example-bare');
const EXAMPLE_MODULE_NEW_FILES = path.resolve(
__dirname,
'../templates/example-module-new'
Expand All @@ -64,13 +65,14 @@ const EXAMPLE_VIEW_FILES = path.resolve(__dirname, '../templates/example-view');
const EXAMPLE_EXPO_FILES = path.resolve(__dirname, '../templates/example-expo');

const JS_FILES = path.resolve(__dirname, '../templates/js-library');
const JS_VIEW_FILES = path.resolve(__dirname, '../templates/js-view');
const NATIVE_COMMON_FILES = path.resolve(
__dirname,
'../templates/native-common'
);
const NATIVE_COMMON_EXAMPLE_FILES = path.resolve(
const EXAMPLE_NATIVE_COMMON_FILES = path.resolve(
__dirname,
'../templates/native-common-example'
'../templates/example-native-common'
);
const NITRO_COMMON_FILES = path.resolve(__dirname, '../templates/nitro-common');

Expand Down Expand Up @@ -203,27 +205,50 @@ export async function applyTemplates(

if (answers.languages === 'js') {
await applyTemplate(config, JS_FILES, folder);
await applyTemplate(config, EXAMPLE_EXPO_FILES, folder);
} else {
await applyTemplate(config, NATIVE_COMMON_FILES, folder);

if (config.example != null) {
await applyTemplate(config, NATIVE_COMMON_EXAMPLE_FILES, folder);
if (config.example === 'expo') {
await applyTemplate(config, EXAMPLE_EXPO_FILES, folder);
} else {
await applyTemplate(config, EXAMPLE_BARE_FILES, folder);
}
}
} else {
await applyTemplate(config, NATIVE_COMMON_FILES, folder);

if (config.example === 'expo') {
await applyTemplate(config, EXAMPLE_EXPO_FILES, folder);

if (config.project.native) {
await applyTemplate(config, EXAMPLE_NATIVE_COMMON_FILES, folder);
}
} else if (config.example != null) {
await applyTemplate(config, EXAMPLE_BARE_FILES, folder);

if (config.project.native) {
await applyTemplate(config, EXAMPLE_NATIVE_COMMON_FILES, folder);
}
}

if (config.project.moduleConfig === 'nitro-modules') {
await applyTemplate(config, NITRO_COMMON_FILES, folder);
await applyTemplate(config, NATIVE_FILES['module_nitro'], folder);

if (config.example === 'expo' || config.tools.includes('vite')) {
await applyTemplate(config, JS_FILES, folder);
}

return;
}

if (config.project.viewConfig === 'nitro-view') {
await applyTemplate(config, NITRO_COMMON_FILES, folder);
await applyTemplate(config, NATIVE_FILES['view_nitro'], folder);

if (config.example === 'expo' || config.tools.includes('vite')) {
await applyTemplate(config, JS_VIEW_FILES, folder);
}

return;
}

Expand All @@ -244,6 +269,14 @@ export async function applyTemplates(
}_new` as const;

await applyTemplate(config, KOTLIN_FILES[templateType], folder);

if (config.example === 'expo' || config.tools.includes('vite')) {
if (config.project.viewConfig !== null) {
await applyTemplate(config, JS_VIEW_FILES, folder);
} else {
await applyTemplate(config, JS_FILES, folder);
}
}
}
}

Expand Down
140 changes: 107 additions & 33 deletions packages/create-react-native-library/src/utils/configureTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,27 @@ import path from 'node:path';
import { applyTemplate, type TemplateConfiguration } from '../template';
import sortObjectKeys from './sortObjectKeys';

type PackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
[key: string]: unknown;
};

type Tool = {
name: string;
description: string;
condition?: (config: TemplateConfiguration) => boolean;
condition?: (context: Pick<TemplateConfiguration, 'example'>) => boolean;
postprocess?: (options: {
config: TemplateConfiguration;
root: string;
}) => void | Promise<void>;
};

type Options = {
tools: string[];
root: string;
packageJson: Record<string, unknown>;
packageJson: PackageJson;
config: TemplateConfiguration;
};

Expand Down Expand Up @@ -41,11 +52,45 @@ const TURBOREPO = {
description: 'Cache build outputs on CI',
};

const VITE: Tool = {
name: 'Vite',
description: 'Add web support to the example app',
condition: (config) => config.example != null && config.example !== 'expo',
postprocess: async ({ root }) => {
const examplePkgPath = path.join(root, 'example', 'package.json');

if (!fs.existsSync(examplePkgPath)) {
throw new Error("Couldn't find the example app's package.json.");
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const examplePackageJson = (await fs.readJson(
examplePkgPath
)) as PackageJson;

const reactVersion =
examplePackageJson.dependencies?.react ??
examplePackageJson.devDependencies?.react;

if (reactVersion == null) {
throw new Error("Couldn't find the package 'react' in the example app.");
}

examplePackageJson.dependencies = sortObjectKeys({
...examplePackageJson.dependencies,
'react-dom': reactVersion,
});

await fs.writeJson(examplePkgPath, examplePackageJson, { spaces: 2 });
},
};

export const AVAILABLE_TOOLS = {
eslint: ESLINT,
jest: JEST,
lefthook: LEFTHOOK,
'release-it': RELEASE_IT,
vite: VITE,
} as const satisfies Record<string, Tool>;

const REQUIRED_TOOLS = {
Expand Down Expand Up @@ -89,44 +134,73 @@ export async function configureTools({
await applyTemplate(config, toolDir, root);
}

const pkgPath = path.join(toolDir, '~package.json');
const examplePkgPath = path.join(toolDir, 'example', '~package.json');

await mergePackageJsonTemplate(
path.join(toolDir, '~package.json'),
packageJson
);

if (fs.existsSync(pkgPath)) {
if (fs.existsSync(examplePkgPath)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolPkg = (await fs.readJson(pkgPath)) as Record<string, unknown>;
const existingExamplePackageJson = (await fs.readJson(
path.join(root, 'example', 'package.json')
)) as PackageJson;

await mergePackageJsonTemplate(
examplePkgPath,
existingExamplePackageJson
);

await fs.writeJson(
path.join(root, 'example', 'package.json'),
existingExamplePackageJson,
{
spaces: 2,
}
);
}

await tool.postprocess?.({ config, root });
}
}

async function mergePackageJsonTemplate(
templatePath: string,
packageJson: PackageJson
) {
if (!fs.existsSync(templatePath)) {
return;
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const template = (await fs.readJson(templatePath)) as PackageJson;

for (const [field, value] of Object.entries(template)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
if (
typeof packageJson[field] === 'object' ||
packageJson[field] == null
) {
packageJson[field] = {
...packageJson[field],
...value,
};

for (const [field, value] of Object.entries(toolPkg)) {
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
field === 'dependencies' ||
field === 'devDependencies' ||
field === 'peerDependencies'
) {
if (
typeof packageJson[field] === 'object' ||
packageJson[field] == null
) {
packageJson[field] = {
...packageJson[field],
...value,
};

if (
field === 'dependencies' ||
field === 'devDependencies' ||
field === 'peerDependencies'
) {
// @ts-expect-error: We know they are objects here
packageJson[field] = sortObjectKeys(packageJson[field]);
}
} else {
throw new Error(
`Cannot merge '${field}' field because it is not an object (got '${String(packageJson[field])}').`
);
}
} else {
packageJson[field] = value;
packageJson[field] = sortObjectKeys(packageJson[field]);
}
} else {
throw new Error(
`Cannot merge '${field}' field because it is not an object (got '${String(packageJson[field])}').`
);
}
} else {
packageJson[field] = value;
}
}
}
Loading
Loading