From bf94f692e972968877f0f400e144fb680044b277 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Fri, 29 Sep 2023 12:20:38 +0200 Subject: [PATCH] feat: add ability to generate a local library (#469) ### Summary Currently, when we have local native modules and views, they become part of the `android` & `ios` source code, this makes upgrading the project to a newer React Native version harder as we also need to consider those changes. This change lets us create a local library which is not part of the android & ios folders, but a separate directory in the repo, making it self contained. It also adds a new `--no-example` flag to generate the project without example. More details in this RFC: https://github.com/react-native-community/discussions-and-proposals/pull/702 ### How it works - If we detect a `package.json` in the current folder, we ask user whether they want to create a local library - If the project is a react-native project, we add entries to `package.json` based on the package manager to link the native library (which creates a symlink in `node_modules`) - If the project is not a react-native project, we don't modify package.json, but ask user to link based on the project setup - this is relevant for monorepo scenario ### Test plan - Create a new project with `npx react-native init` - Run `create-react-native-library` in the project to create a local library - Import the library in `App.tsx` and use the exported method to verify functionality - Install dependencies and pods, then run the app on Android & iOS to verify that it works https://github.com/callstack/react-native-builder-bob/assets/1174278/d1da73f7-8a57-4911-9200-07c937d3b940 --- .github/workflows/build-templates.yml | 3 +- .../create-react-native-library/src/index.ts | 269 ++++++++++++++---- .../src/utils/generateExampleApp.ts | 13 +- .../example/metro.config.js | 0 .../example/src/App.tsx | 0 .../templates/common-local/$package.json | 16 ++ .../templates/common/$package.json | 7 +- .../example/react-native.config.js | 0 .../scripts/pod-install.cjs | 0 .../turbo.json | 0 .../react-native-builder-bob/src/index.ts | 59 ++-- .../src/utils/compile.ts | 2 +- 12 files changed, 278 insertions(+), 91 deletions(-) rename packages/create-react-native-library/templates/{common => common-example}/example/metro.config.js (100%) rename packages/create-react-native-library/templates/{common => common-example}/example/src/App.tsx (100%) create mode 100644 packages/create-react-native-library/templates/common-local/$package.json rename packages/create-react-native-library/templates/{native-common => native-common-example}/example/react-native.config.js (100%) rename packages/create-react-native-library/templates/{native-common => native-common-example}/scripts/pod-install.cjs (100%) rename packages/create-react-native-library/templates/{native-common => native-common-example}/turbo.json (100%) diff --git a/.github/workflows/build-templates.yml b/.github/workflows/build-templates.yml index 20b87c57..c5a1de29 100644 --- a/.github/workflows/build-templates.yml +++ b/.github/workflows/build-templates.yml @@ -112,7 +112,8 @@ jobs: --author-url https://test.test \ --repo-url https://test.test \ --type ${{ matrix.type }} \ - --languages ${{ matrix.language }} + --languages ${{ matrix.language }} \ + --no-local - name: Cache dependencies of library id: library-yarn-cache diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index 16dc91df..f0d5c67d 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -19,6 +19,11 @@ const BINARIES = [ ]; const COMMON_FILES = path.resolve(__dirname, '../templates/common'); +const COMMON_EXAMPLE_FILES = path.resolve( + __dirname, + '../templates/common-example' +); +const COMMON_LOCAL_FILES = path.resolve(__dirname, '../templates/common-local'); const JS_FILES = path.resolve(__dirname, '../templates/js-library'); const EXPO_FILES = path.resolve(__dirname, '../templates/expo-library'); const CPP_FILES = path.resolve(__dirname, '../templates/cpp-library'); @@ -27,6 +32,10 @@ const NATIVE_COMMON_FILES = path.resolve( __dirname, '../templates/native-common' ); +const NATIVE_COMMON_EXAMPLE_FILES = path.resolve( + __dirname, + '../templates/native-common-example' +); const NATIVE_FILES = { module_legacy: path.resolve(__dirname, '../templates/native-library-legacy'), @@ -82,6 +91,8 @@ type ArgName = | 'repo-url' | 'languages' | 'type' + | 'local' + | 'example' | 'react-native-version'; type ProjectLanguages = @@ -110,6 +121,7 @@ type Answers = { repoUrl: string; languages: ProjectLanguages; type?: ProjectType; + example?: boolean; reactNativeVersion?: string; }; @@ -246,10 +258,68 @@ const args: Record = { description: 'Version of React Native to use, uses latest if not specified', type: 'string', }, + 'local': { + description: 'Whether to create a local library', + type: 'boolean', + }, + 'example': { + description: 'Whether to create an example app', + type: 'boolean', + default: true, + }, }; async function create(argv: yargs.Arguments) { - const folder = path.join(process.cwd(), argv.name); + let local = false; + + if (typeof argv.local === 'boolean') { + local = argv.local; + } else { + let hasPackageJson = await fs.pathExists( + path.join(process.cwd(), 'package.json') + ); + + if (hasPackageJson) { + // If we're under a project with package.json, ask the user if they want to create a local library + const answers = await prompts({ + type: 'confirm', + name: 'local', + message: `Looks like you're under a project folder. Do you want to create a local library?`, + initial: true, + }); + + local = answers.local; + } + } + + let folder; + + if (argv.name && !local) { + folder = path.join(process.cwd(), argv.name); + } else { + const answers = await prompts({ + type: 'text', + name: 'folder', + message: `Where do you want to create the library?`, + initial: + local && argv.name && !argv.name.includes('/') + ? `modules/${argv.name}` + : argv.name, + validate: (input) => { + if (!input) { + return 'Cannot be empty'; + } + + if (fs.pathExistsSync(path.join(process.cwd(), input))) { + return 'Folder already exists'; + } + + return true; + }, + }); + + folder = path.join(process.cwd(), answers.folder); + } if (await fs.pathExists(folder)) { console.log( @@ -287,7 +357,7 @@ async function create(argv: yargs.Arguments) { // Ignore error } - const basename = path.basename(argv.name); + const basename = path.basename(folder); const questions: Record< ArgName, @@ -315,14 +385,14 @@ async function create(argv: yargs.Arguments) { validate: (input) => Boolean(input) || 'Cannot be empty', }, 'author-name': { - type: 'text', + type: local ? null : 'text', name: 'authorName', message: 'What is the name of package author?', initial: name, validate: (input) => Boolean(input) || 'Cannot be empty', }, 'author-email': { - type: 'text', + type: local ? null : 'text', name: 'authorEmail', message: 'What is the email address for the package author?', initial: email, @@ -330,7 +400,7 @@ async function create(argv: yargs.Arguments) { /^\S+@\S+$/.test(input) || 'Must be a valid email address', }, 'author-url': { - type: 'text', + type: local ? null : 'text', name: 'authorUrl', message: 'What is the URL for the package author?', // @ts-ignore: this is supported, but types are wrong @@ -348,7 +418,7 @@ async function create(argv: yargs.Arguments) { validate: (input) => /^https?:\/\//.test(input) || 'Must be a valid URL', }, 'repo-url': { - type: 'text', + type: local ? null : 'text', name: 'repoUrl', message: 'What is the URL for the repository?', // @ts-ignore: this is supported, but types are wrong @@ -438,6 +508,7 @@ async function create(argv: yargs.Arguments) { repoUrl, type = 'module-mixed', languages = type === 'library' ? 'js' : 'java-objc', + example: hasExample, reactNativeVersion, } = { ...argv, @@ -503,7 +574,9 @@ async function create(argv: yargs.Arguments) { ? 'mixed' : 'legacy'; - const example = type === 'library' ? 'expo' : 'native'; + const example = + hasExample && !local ? (type === 'library' ? 'expo' : 'native') : 'none'; + const project = slug.replace(/^(react-native-|@[^/]+\/)/, ''); let namespace: string | undefined; @@ -528,9 +601,9 @@ async function create(argv: yargs.Arguments) { slug, description, name: - /^[A-Z]/.test(argv.name) && /^[a-z0-9]+$/i.test(argv.name) + /^[A-Z]/.test(basename) && /^[a-z0-9]+$/i.test(basename) ? // If the project name is already in PascalCase, use it as-is - argv.name + basename : // Otherwise, convert it to PascalCase and remove any non-alphanumeric characters `${project.charAt(0).toUpperCase()}${project .replace(/[^a-z0-9](\w)/g, (_, $1) => $1.toUpperCase()) @@ -602,32 +675,50 @@ async function create(argv: yargs.Arguments) { } } - const spinner = ora('Generating example').start(); + const spinner = ora().start(); - await generateExampleApp({ - type: example, - dest: folder, - slug: options.project.slug, - projectName: options.project.name, - arch, - reactNativeVersion, - }); + if (example !== 'none') { + spinner.text = 'Generating example app'; + + await generateExampleApp({ + type: example, + dest: folder, + slug: options.project.slug, + projectName: options.project.name, + arch, + reactNativeVersion, + }); + } spinner.text = 'Copying files'; - await copyDir(COMMON_FILES, folder); + if (local) { + await copyDir(COMMON_LOCAL_FILES, folder); + } else { + await copyDir(COMMON_FILES, folder); + + if (example !== 'none') { + await copyDir(COMMON_EXAMPLE_FILES, folder); + } + } if (languages === 'js') { await copyDir(JS_FILES, folder); await copyDir(EXPO_FILES, folder); } else { - await copyDir( - path.join(EXAMPLE_FILES, 'example'), - path.join(folder, 'example') - ); + if (example !== 'none') { + await copyDir( + path.join(EXAMPLE_FILES, 'example'), + path.join(folder, 'example') + ); + } await copyDir(NATIVE_COMMON_FILES, folder); + if (example !== 'none') { + await copyDir(NATIVE_COMMON_EXAMPLE_FILES, folder); + } + if (moduleType === 'module') { await copyDir(NATIVE_FILES[`${moduleType}_${arch}`], folder); } else { @@ -664,44 +755,113 @@ async function create(argv: yargs.Arguments) { } } - // Set `react` and `react-native` versions of root `package.json` from example `package.json` - const examplePackageJson = fs.readJSONSync( - path.join(folder, 'example', 'package.json') - ); - const rootPackageJson = fs.readJSONSync(path.join(folder, 'package.json')); - rootPackageJson.devDependencies.react = examplePackageJson.dependencies.react; - rootPackageJson.devDependencies['react-native'] = - examplePackageJson.dependencies['react-native']; + if (example !== 'none') { + // Set `react` and `react-native` versions of root `package.json` from example `package.json` + const examplePackageJson = await fs.readJSON( + path.join(folder, 'example', 'package.json') + ); + const rootPackageJson = await fs.readJSON( + path.join(folder, 'package.json') + ); - fs.writeJSONSync(path.join(folder, 'package.json'), rootPackageJson, { - spaces: 2, - }); + rootPackageJson.devDependencies.react = + examplePackageJson.dependencies.react; + rootPackageJson.devDependencies['react-native'] = + examplePackageJson.dependencies['react-native']; - try { - await spawn('git', ['init'], { cwd: folder }); - await spawn('git', ['branch', '-M', 'main'], { cwd: folder }); - await spawn('git', ['add', '.'], { cwd: folder }); - await spawn('git', ['commit', '-m', 'chore: initial commit'], { - cwd: folder, + await fs.writeJSON(path.join(folder, 'package.json'), rootPackageJson, { + spaces: 2, }); - } catch (e) { - // Ignore error + } + + if (!local) { + try { + await spawn('git', ['init'], { cwd: folder }); + await spawn('git', ['branch', '-M', 'main'], { cwd: folder }); + await spawn('git', ['add', '.'], { cwd: folder }); + await spawn('git', ['commit', '-m', 'chore: initial commit'], { + cwd: folder, + }); + } catch (e) { + // Ignore error + } } spinner.succeed( - `Project created successfully at ${kleur.yellow(argv.name)}!\n` + `Project created successfully at ${kleur.yellow( + path.relative(process.cwd(), folder) + )}!\n` ); - const platforms = { - ios: { name: 'iOS', color: 'cyan' }, - android: { name: 'Android', color: 'green' }, - ...(example === 'expo' - ? ({ web: { name: 'Web', color: 'blue' } } as const) - : null), - } as const; + if (local) { + let linked; + + const packageManager = (await fs.pathExists( + path.join(process.cwd(), 'yarn.lock') + )) + ? 'yarn' + : 'npm'; - console.log( - dedent(` + const packageJsonPath = path.join(process.cwd(), 'package.json'); + + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJSON(packageJsonPath); + const isReactNativeProject = Boolean( + packageJson.dependencies?.['react-native'] + ); + + if (isReactNativeProject) { + packageJson.dependencies = packageJson.dependencies || {}; + packageJson.dependencies[slug] = + packageManager === 'yarn' + ? `link:./${path.relative(process.cwd(), folder)}` + : `file:./${path.relative(process.cwd(), folder)}`; + + await fs.writeJSON(packageJsonPath, packageJson, { + spaces: 2, + }); + + linked = true; + } + } + + console.log( + dedent(` + ${kleur.magenta( + `${kleur.bold('Get started')} with the project` + )}${kleur.gray(':')} + + ${ + (linked + ? `- Run ${kleur.blue( + `${packageManager} install` + )} to link the library\n` + : `- Link the library at ${kleur.blue( + path.relative(process.cwd(), folder) + )} based on your project setup'\n`) + + `- Run ${kleur.blue( + 'pod install --project-directory=ios' + )} to install dependencies with CocoaPods\n` + + `- Run ${kleur.blue('npx react-native run-android')} or ${kleur.blue( + 'npx react-native run-ios' + )} to build and run the app\n` + + `- Import from ${kleur.blue(slug)} and use it in your app.` + } + + ${kleur.yellow(`Good luck!`)} + `) + ); + } else { + const platforms = { + ios: { name: 'iOS', color: 'cyan' }, + android: { name: 'Android', color: 'green' }, + ...(example === 'expo' + ? ({ web: { name: 'Web', color: 'blue' } } as const) + : null), + } as const; + + console.log( + dedent(` ${kleur.magenta( `${kleur.bold('Get started')} with the project` )}${kleur.gray(':')} @@ -722,11 +882,12 @@ async function create(argv: yargs.Arguments) { `See ${kleur.bold('CONTRIBUTING.md')} for more details. Good luck!` )} `) - ); + ); + } } // eslint-disable-next-line babel/no-unused-expressions yargs - .command('$0 ', 'create a react native library', args, create) + .command('$0 [name]', 'create a react native library', args, create) .demandCommand() .recommendCommands() .fail((message, error) => { diff --git a/packages/create-react-native-library/src/utils/generateExampleApp.ts b/packages/create-react-native-library/src/utils/generateExampleApp.ts index d33e411c..de52dcb3 100644 --- a/packages/create-react-native-library/src/utils/generateExampleApp.ts +++ b/packages/create-react-native-library/src/utils/generateExampleApp.ts @@ -47,6 +47,10 @@ const PACKAGES_TO_ADD_WEB_DEV = { 'babel-loader': '^8.1.0', }; +const PACKAGES_TO_ADD_NATIVE_DEV = { + 'pod-install': '^0.1.0', +}; + export default async function generateExampleApp({ type, dest, @@ -160,12 +164,13 @@ export default async function generateExampleApp({ }); scripts.web = 'expo start --web'; + } else { + Object.assign(devDependencies, PACKAGES_TO_ADD_NATIVE_DEV); } - await fs.writeFile( - path.join(directory, 'package.json'), - JSON.stringify(pkg, null, 2) - ); + await fs.writeJSON(path.join(directory, 'package.json'), pkg, { + spaces: 2, + }); // If the library is on new architecture, enable new arch for iOS and Android if (arch === 'new') { diff --git a/packages/create-react-native-library/templates/common/example/metro.config.js b/packages/create-react-native-library/templates/common-example/example/metro.config.js similarity index 100% rename from packages/create-react-native-library/templates/common/example/metro.config.js rename to packages/create-react-native-library/templates/common-example/example/metro.config.js diff --git a/packages/create-react-native-library/templates/common/example/src/App.tsx b/packages/create-react-native-library/templates/common-example/example/src/App.tsx similarity index 100% rename from packages/create-react-native-library/templates/common/example/src/App.tsx rename to packages/create-react-native-library/templates/common-example/example/src/App.tsx diff --git a/packages/create-react-native-library/templates/common-local/$package.json b/packages/create-react-native-library/templates/common-local/$package.json new file mode 100644 index 00000000..e3b23099 --- /dev/null +++ b/packages/create-react-native-library/templates/common-local/$package.json @@ -0,0 +1,16 @@ +{ + "name": "<%- project.slug -%>", + "version": "0.0.0", + "description": "<%- project.description %>", + "main": "src/index", +<% if (project.arch !== 'legacy') { -%> + "codegenConfig": { + "name": "RN<%- project.name -%><%- project.view ? 'View': '' -%>Spec", + "type": <%- project.view ? '"components"': '"modules"' %>, + "jsSrcsDir": "src" + }, +<% } -%> + "author": "<%- author.name -%> <<%- author.email -%>> (<%- author.url -%>)", + "license": "UNLICENSED", + "homepage": "<%- repo -%>#readme" +} diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index cbda1fc5..e00e9574 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -14,7 +14,6 @@ "ios", "cpp", "*.podspec", - "!lib/typescript/example", "!ios/build", "!android/build", "!android/gradle", @@ -27,7 +26,9 @@ "!**/.*" ], "scripts": { +<% if (example !== 'none') { -%> "example": "yarn workspace <%- project.slug -%>-example", +<% } -%> "test": "jest", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", @@ -68,7 +69,9 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "jest": "^28.1.1", +<% if (example === 'native') { -%> "pod-install": "^0.1.0", +<% } -%> "prettier": "^2.0.5", "react": "17.0.2", "react-native": "0.70.0", @@ -86,9 +89,11 @@ "react": "*", "react-native": "*" }, +<% if (example !== 'none') { -%> "workspaces": [ "example" ], +<% } -%> "packageManager": "yarn@3.6.1", "engines": { "node": ">= 18.0.0" diff --git a/packages/create-react-native-library/templates/native-common/example/react-native.config.js b/packages/create-react-native-library/templates/native-common-example/example/react-native.config.js similarity index 100% rename from packages/create-react-native-library/templates/native-common/example/react-native.config.js rename to packages/create-react-native-library/templates/native-common-example/example/react-native.config.js diff --git a/packages/create-react-native-library/templates/native-common/scripts/pod-install.cjs b/packages/create-react-native-library/templates/native-common-example/scripts/pod-install.cjs similarity index 100% rename from packages/create-react-native-library/templates/native-common/scripts/pod-install.cjs rename to packages/create-react-native-library/templates/native-common-example/scripts/pod-install.cjs diff --git a/packages/create-react-native-library/templates/native-common/turbo.json b/packages/create-react-native-library/templates/native-common-example/turbo.json similarity index 100% rename from packages/create-react-native-library/templates/native-common/turbo.json rename to packages/create-react-native-library/templates/native-common-example/turbo.json diff --git a/packages/react-native-builder-bob/src/index.ts b/packages/react-native-builder-bob/src/index.ts index 23417107..47428d8b 100644 --- a/packages/react-native-builder-bob/src/index.ts +++ b/packages/react-native-builder-bob/src/index.ts @@ -172,37 +172,34 @@ yargs }); if (tsconfig) { - await fs.writeFile( + await fs.writeJSON( path.join(root, 'tsconfig.json'), - JSON.stringify( - { - compilerOptions: { - rootDir: '.', - allowUnreachableCode: false, - allowUnusedLabels: false, - esModuleInterop: true, - forceConsistentCasingInFileNames: true, - jsx: 'react', - lib: ['esnext'], - module: 'esnext', - moduleResolution: 'node', - noFallthroughCasesInSwitch: true, - noImplicitReturns: true, - noImplicitUseStrict: false, - noStrictGenericChecks: false, - noUncheckedIndexedAccess: true, - noUnusedLocals: true, - noUnusedParameters: true, - resolveJsonModule: true, - skipLibCheck: true, - strict: true, - target: 'esnext', - verbatimModuleSyntax: true, - }, + { + compilerOptions: { + rootDir: '.', + allowUnreachableCode: false, + allowUnusedLabels: false, + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + jsx: 'react', + lib: ['esnext'], + module: 'esnext', + moduleResolution: 'node', + noFallthroughCasesInSwitch: true, + noImplicitReturns: true, + noImplicitUseStrict: false, + noStrictGenericChecks: false, + noUncheckedIndexedAccess: true, + noUnusedLocals: true, + noUnusedParameters: true, + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + target: 'esnext', + verbatimModuleSyntax: true, }, - null, - 2 - ) + }, + { spaces: 2 } ); } } @@ -308,7 +305,9 @@ yargs pkg.eslintIgnore.push(`${output}/`); } - await fs.writeFile(pak, JSON.stringify(pkg, null, 2)); + await fs.writeJSON(pak, pkg, { + spaces: 2, + }); const ignorefiles = [ path.join(root, '.gitignore'), diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index 4d089f6b..0e788285 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -136,7 +136,7 @@ export default async function compile({ // Don't inline the source code, it can be retrieved from the source file result.map.sourcesContent = undefined; - fs.writeFileSync(mapFilename, JSON.stringify(result.map)); + await fs.writeJSON(mapFilename, result.map); } await fs.writeFile(outputFilename, code);