From afacef2d3e5dab7d5ad6e04b636337d50c5cf850 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Wed, 26 Mar 2025 15:09:48 +0100 Subject: [PATCH] refactor: improve how esm is handled in bob --- docs/pages/build.md | 32 +- docs/pages/esm.md | 10 +- .../templates/common/$package.json | 3 +- .../templates/common/tsconfig.json | 2 +- .../react-native-builder-bob/package.json | 3 +- .../__tests__/__snapshots__/init.test.ts.snap | 91 +++ .../{index.test.ts => babel.test.ts} | 0 .../src/__tests__/init.test.ts | 121 ++++ .../react-native-builder-bob/src/build.ts | 217 +++++++ .../react-native-builder-bob/src/index.ts | 574 +----------------- packages/react-native-builder-bob/src/init.ts | 466 ++++++++++++++ .../src/targets/commonjs.ts | 15 +- .../src/targets/module.ts | 15 +- .../src/targets/typescript.ts | 88 ++- .../react-native-builder-bob/src/types.ts | 5 + .../src/utils/compile.ts | 108 ++-- .../src/utils/loadConfig.ts | 19 + yarn.lock | 14 +- 18 files changed, 1107 insertions(+), 676 deletions(-) create mode 100644 packages/react-native-builder-bob/src/__tests__/__snapshots__/init.test.ts.snap rename packages/react-native-builder-bob/src/__tests__/{index.test.ts => babel.test.ts} (100%) create mode 100644 packages/react-native-builder-bob/src/__tests__/init.test.ts create mode 100644 packages/react-native-builder-bob/src/build.ts create mode 100644 packages/react-native-builder-bob/src/init.ts create mode 100644 packages/react-native-builder-bob/src/utils/loadConfig.ts diff --git a/docs/pages/build.md b/docs/pages/build.md index 564fe876d..8676d6d0c 100644 --- a/docs/pages/build.md +++ b/docs/pages/build.md @@ -4,10 +4,10 @@ When code is in non-standard syntaxes such as JSX, TypeScript etc, it needs to b Supported targets are: -- Generic CommonJS build -- ES modules build for bundlers such as [webpack](https://webpack.js.org) +- [ES modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) build for modern tools +- [CommonJS](https://nodejs.org/api/modules.html#modules-commonjs-modules) build for legacy tools - [TypeScript](https://www.typescriptlang.org/) definitions -- Flow definitions (copies .js files to .flow files) +- [Flow](https://flow.org/) definitions (copies .js files to .flow files) - [Codegen](https://reactnative.dev/docs/the-new-architecture/what-is-codegen) generated scaffold code If you created a project with [`create-react-native-library`](./create.md), `react-native-builder-bob` is **already pre-configured to build your project**. You don't need to configure it again. @@ -24,7 +24,7 @@ npx react-native-builder-bob@latest init This will ask you a few questions and add the required configuration and scripts for building the code. The code will be compiled automatically when the package is published. -> Note: the `init` command doesn't add the `codegen` target yet. You can either add it manually or create a new library with `create-react-native-library`. +> Note: the `init` command doesn't add the [`codegen` target](#codegen) yet. You can either add it manually or create a new library with `create-react-native-library`. You can find details on what exactly it adds in the [Manual configuration](#manual-configuration) section. @@ -34,28 +34,28 @@ To configure your project manually, follow these steps: 1. First, install `react-native-builder-bob` in your project. Open a Terminal in your project, and run: -```sh -yarn add --dev react-native-builder-bob -``` + ```sh + yarn add --dev react-native-builder-bob + ``` -1. In your `package.json`, specify the targets to build for: +2. In your `package.json`, specify the targets to build for: ```json "react-native-builder-bob": { "source": "src", "output": "lib", "targets": [ - "codegen", ["commonjs", { "esm": true }], ["module", { "esm": true }], - ["typescript", { "esm": true }] + "typescript", + "codegen", ] } ``` See the [Options](#options) section for more details. -1. Add `bob` to your `prepare` or `prepack` step: +3. Add `bob` to your `prepare` or `prepack` step: ```js "scripts": { @@ -74,7 +74,7 @@ yarn add --dev react-native-builder-bob If you are not sure which one to use, we recommend going with `prepare` as it works during both publishing and installing from GIT with more package managers. -1. Configure the appropriate entry points: +4. Configure the appropriate entry points: ```json "source": "./src/index.tsx", @@ -112,7 +112,7 @@ yarn add --dev react-native-builder-bob > If you're building TypeScript definition files, also make sure that the `types` field points to a correct path. Depending on the project configuration, the path can be different for you than the example snippet (e.g. `lib/typescript/index.d.ts` if you have only the `src` directory and `rootDir` is not set). -1. Add the output directory to `.gitignore` and `.eslintignore` +5. Add the output directory to `.gitignore` and `.eslintignore` ```gitignore # generated files by bob @@ -121,7 +121,7 @@ yarn add --dev react-native-builder-bob This makes sure that you don't accidentally commit the generated files to git or get lint errors for them. -1. Add the output directory to `jest.modulePathIgnorePatterns` if you use [Jest](https://jestjs.io) +6. Add the output directory to `jest.modulePathIgnorePatterns` if you use [Jest](https://jestjs.io) ```json "modulePathIgnorePatterns": ["/lib/"] @@ -129,7 +129,7 @@ yarn add --dev react-native-builder-bob This makes sure that Jest doesn't try to run the tests in the generated files. -1. Configure [React Native Codegen](https://reactnative.dev/docs/the-new-architecture/what-is-codegen) +7. Configure [React Native Codegen](https://reactnative.dev/docs/the-new-architecture/what-is-codegen) If your library supports the [New React Native Architecture](https://reactnative.dev/architecture/landing-page), you should also configure Codegen. This is not required for libraries that only support the old architecture. @@ -262,6 +262,8 @@ Example: Enable generating type definitions with `tsc` if your source code is written in [TypeScript](http://www.typescriptlang.org/). +When both `module` and `commonjs` targets are enabled, and `esm` is set to `true` for the `module` target, this will output 2 sets of type definitions: one for the CommonJS build and one for the ES module build. + The following options are supported: ##### `project` diff --git a/docs/pages/esm.md b/docs/pages/esm.md index 0877dbc61..ab0ace359 100644 --- a/docs/pages/esm.md +++ b/docs/pages/esm.md @@ -11,25 +11,25 @@ You can verify whether ESM support is enabled by checking the configuration for "targets": [ ["commonjs", { "esm": true }], ["module", { "esm": true }], - ["typescript", { "esm": true }] + "typescript" ] } ``` The `"esm": true` option enables ESM-compatible output by adding the `.js` extension to the import statements in the generated files. For TypeScript, it also generates 2 sets of type definitions: one for the CommonJS build and one for the ES module build. -It's recommended to specify `"moduleResolution": "Bundler"` and `"resolvePackageJsonImports": false` in your `tsconfig.json` file to match [Metro's behavior](https://reactnative.dev/blog/2023/06/21/package-exports-support#enabling-package-exports-beta): +It's recommended to specify `"moduleResolution": "bundler"` and `"resolvePackageJsonImports": false` in your `tsconfig.json` file to match [Metro's behavior](https://reactnative.dev/blog/2023/06/21/package-exports-support#enabling-package-exports-beta): ```json { "compilerOptions": { - "moduleResolution": "Bundler", + "moduleResolution": "bundler", "resolvePackageJsonImports": false } } ``` -Specifying `"moduleResolution": "Bundler"` means that you don't need to use file extensions in the import statements. Bob automatically adds them when possible during the build process. +Specifying `"moduleResolution": "bundler"` means that you don't need to use file extensions in the import statements. Bob automatically adds them when possible during the build process. To make use of the output files, ensure that your `package.json` file contains the following fields: @@ -80,7 +80,7 @@ There are still a few things to keep in mind if you want your library to be ESM- ``` - Avoid using `.cjs`, `.mjs`, `.cts` or `.mts` extensions. Metro always requires file extensions in import statements when using `.cjs` or `.mjs` which breaks platform-specific extension resolution. -- Avoid using `"moduleResolution": "Node16"` or `"moduleResolution": "NodeNext"` in your `tsconfig.json` file. They require file extensions in import statements which breaks platform-specific extension resolution. +- Avoid using `"moduleResolution": "node16"` or `"moduleResolution": "nodenext"` in your `tsconfig.json` file. They require file extensions in import statements which breaks platform-specific extension resolution. - If you specify a `react-native` condition in `exports`, make sure that it comes before `import` or `require`. The conditions should be ordered from the most specific to the least specific: ```json diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index 967fbaa3b..cd773f352 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -217,8 +217,7 @@ [ "typescript", { - "project": "tsconfig.build.json", - "esm": true + "project": "tsconfig.build.json" } ] ] diff --git a/packages/create-react-native-library/templates/common/tsconfig.json b/packages/create-react-native-library/templates/common/tsconfig.json index 392bd210a..a7f008fe0 100644 --- a/packages/create-react-native-library/templates/common/tsconfig.json +++ b/packages/create-react-native-library/templates/common/tsconfig.json @@ -11,7 +11,7 @@ "jsx": "react-jsx", "lib": ["ESNext"], "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "bundler", "noEmit": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, diff --git a/packages/react-native-builder-bob/package.json b/packages/react-native-builder-bob/package.json index fd8dc5838..f74eb0416 100644 --- a/packages/react-native-builder-bob/package.json +++ b/packages/react-native-builder-bob/package.json @@ -85,6 +85,7 @@ "@types/yargs": "^17.0.10", "concurrently": "^7.2.2", "jest": "^29.7.0", - "mock-fs": "^5.2.0" + "mock-fs": "^5.2.0", + "mock-stdin": "^1.0.0" } } diff --git a/packages/react-native-builder-bob/src/__tests__/__snapshots__/init.test.ts.snap b/packages/react-native-builder-bob/src/__tests__/__snapshots__/init.test.ts.snap new file mode 100644 index 000000000..5817a799e --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/__snapshots__/init.test.ts.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`initializes the configuration 1`] = ` +"{ + "name": "library", + "version": "1.0.0", + "devDependencies": { + "react-native-builder-bob": "^0.38.1" + }, + "exports": { + ".": { + "import": { + "types": "./lib/typescript/module/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "require": { + "types": "./lib/typescript/commonjs/src/index.d.ts", + "default": "./lib/commonjs/index.js" + } + } + }, + "source": "./src/index.ts", + "main": "./lib/commonjs/index.js", + "module": "./lib/module/index.js", + "scripts": { + "prepare": "bob build" + }, + "files": [ + "src", + "lib", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__" + ], + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "commonjs", + { + "esm": true + } + ], + "typescript" + ] + }, + "eslintIgnore": [ + "node_modules/", + "lib/" + ] +} +" +`; + +exports[`initializes the configuration 2`] = ` +"{ + "compilerOptions": { + "rootDir": ".", + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": [ + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} +" +`; diff --git a/packages/react-native-builder-bob/src/__tests__/index.test.ts b/packages/react-native-builder-bob/src/__tests__/babel.test.ts similarity index 100% rename from packages/react-native-builder-bob/src/__tests__/index.test.ts rename to packages/react-native-builder-bob/src/__tests__/babel.test.ts diff --git a/packages/react-native-builder-bob/src/__tests__/init.test.ts b/packages/react-native-builder-bob/src/__tests__/init.test.ts new file mode 100644 index 000000000..c2134301d --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/init.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, expect, it, jest } from '@jest/globals'; +import { readFile } from 'fs-extra'; +import mockFs from 'mock-fs'; +import { stdin } from 'mock-stdin'; +import { join } from 'path'; +import { init } from '../init'; + +let io: ReturnType | undefined; + +const root = '/path/to/library'; + +const enter = '\x0D'; + +const waitFor = async (callback: () => void) => { + const interval = 10; + + let timeout = 50; + + return new Promise((resolve, reject) => { + const intervalId = setInterval(() => { + try { + callback(); + clearInterval(intervalId); + resolve(undefined); + } catch (error) { + if (timeout <= 0) { + clearInterval(intervalId); + reject(error); + } + + timeout -= interval; + } + }, interval); + }); +}; + +beforeEach(() => { + io = stdin(); + + mockFs({ + [root]: { + 'package.json': JSON.stringify({ + name: 'library', + version: '1.0.0', + }), + 'src': { + 'index.ts': "export default 'hello world';", + }, + }, + }); +}); + +afterEach(() => { + io?.restore(); + mockFs.restore(); + jest.restoreAllMocks(); +}); + +it('initializes the configuration', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + process.chdir(root); + + const run = async () => { + await waitFor(() => { + const lastCall = (process.stdout.write as jest.Mock).mock.lastCall; + + if (lastCall == null) { + throw new Error('No output'); + } + + if (/The working directory is not clean/.test(String(lastCall[0]))) { + io?.send('y'); + } + }); + + await waitFor(() => + expect(process.stdout.write).toHaveBeenLastCalledWith( + expect.stringMatching('Where are your source files?') + ) + ); + + io?.send(enter); + + await waitFor(() => + expect(process.stdout.write).toHaveBeenLastCalledWith( + expect.stringMatching('Where do you want to generate the output files?') + ) + ); + + io?.send(enter); + + await waitFor(() => + expect(process.stdout.write).toHaveBeenLastCalledWith( + expect.stringMatching('Which targets do you want to build?') + ) + ); + + io?.send(enter); + + await waitFor(() => + expect(process.stdout.write).toHaveBeenLastCalledWith( + expect.stringMatching( + "You have enabled 'typescript' compilation, but we couldn't find a 'tsconfig.json' in project root" + ) + ) + ); + + io?.send(enter); + }; + + await Promise.all([run(), init()]); + + expect(process.stdout.write).toHaveBeenLastCalledWith( + expect.stringMatching('configured successfully!') + ); + + expect(await readFile(join(root, 'package.json'), 'utf8')).toMatchSnapshot(); + + expect(await readFile(join(root, 'tsconfig.json'), 'utf8')).toMatchSnapshot(); +}); diff --git a/packages/react-native-builder-bob/src/build.ts b/packages/react-native-builder-bob/src/build.ts new file mode 100644 index 000000000..db3766ea1 --- /dev/null +++ b/packages/react-native-builder-bob/src/build.ts @@ -0,0 +1,217 @@ +import path from 'path'; +import kleur from 'kleur'; +import * as logger from './utils/logger'; +import buildCommonJS from './targets/commonjs'; +import buildModule from './targets/module'; +import buildTypescript from './targets/typescript'; +import buildCodegen from './targets/codegen'; +import customTarget from './targets/custom'; +import { type Options, type Target } from './types'; +import fs from 'fs-extra'; +import { loadConfig } from './utils/loadConfig'; +import yargs from 'yargs'; + +export const args = { + target: { + type: 'string', + description: 'The target to build', + choices: ['commonjs', 'module', 'typescript', 'codegen'] satisfies Target[], + }, +} satisfies Record<'target', yargs.Options>; + +type Argv = { + $0: string; + target?: Target; +}; + +export async function build(argv: Argv) { + const root = process.cwd(); + + const projectPackagePath = path.resolve(root, 'package.json'); + + if (!(await fs.pathExists(projectPackagePath))) { + throw new Error( + `Couldn't find a 'package.json' file in '${root}'. Are you in a project folder?` + ); + } + + const result = await loadConfig(); + + if (!result?.config) { + logger.error( + `No configuration found. Run '${argv.$0} init' to create one automatically.` + ); + process.exit(1); + } + + const options: Options = result!.config; + + if (!options.targets?.length) { + logger.error( + `No targets found in the configuration in '${path.relative( + root, + result!.filepath + )}'.` + ); + process.exit(1); + } + + const source = options.source; + + if (!source) { + logger.error( + `No source option found in the configuration in '${path.relative( + root, + result!.filepath + )}'.` + ); + process.exit(1); + } + + const output = options.output; + + if (!output) { + logger.error( + `No source option found in the configuration in '${path.relative( + root, + result!.filepath + )}'.` + ); + process.exit(1); + } + + const exclude = options.exclude ?? '**/{__tests__,__fixtures__,__mocks__}/**'; + + const commonjs = options.targets?.some((t) => + Array.isArray(t) ? t[0] === 'commonjs' : t === 'commonjs' + ); + + const module = options.targets?.some((t) => + Array.isArray(t) ? t[0] === 'module' : t === 'module' + ); + + const variants = { + commonjs, + module, + }; + + if (argv.target != null) { + buildTarget({ + root, + target: argv.target, + source, + output, + exclude, + options, + variants, + }); + } else { + for (const target of options.targets!) { + buildTarget({ + root, + target, + source, + output, + exclude, + options, + variants, + }); + } + } +} + +async function buildTarget({ + root, + target, + source, + output, + exclude, + options, + variants, +}: { + root: string; + target: Exclude[number]; + source: string; + output: string; + exclude: string; + options: Options; + variants: { + commonjs?: boolean; + module?: boolean; + }; +}) { + const targetName = Array.isArray(target) ? target[0] : target; + const targetOptions = Array.isArray(target) ? target[1] : undefined; + + const report = logger.grouped(targetName); + + switch (targetName) { + case 'commonjs': + await buildCommonJS({ + root, + source: path.resolve(root, source), + output: path.resolve(root, output, 'commonjs'), + exclude, + options: targetOptions, + variants, + report, + }); + break; + case 'module': + await buildModule({ + root, + source: path.resolve(root, source), + output: path.resolve(root, output, 'module'), + exclude, + options: targetOptions, + variants, + report, + }); + break; + case 'typescript': + { + const esm = + options.targets?.some((t) => { + if (Array.isArray(t)) { + const [name, options] = t; + + if (name === 'module') { + return options && 'esm' in options && options?.esm; + } + } + + return false; + }) ?? false; + + await buildTypescript({ + root, + source: path.resolve(root, source), + output: path.resolve(root, output, 'typescript'), + options: targetOptions, + esm, + variants, + report, + }); + } + break; + case 'codegen': + await buildCodegen({ + root, + source: path.resolve(root, source), + output: path.resolve(root, output, 'typescript'), + report, + }); + break; + case 'custom': + await customTarget({ + options: targetOptions, + source: path.resolve(root, source), + report, + root, + }); + break; + default: + logger.error(`Invalid target ${kleur.blue(targetName)}.`); + process.exit(1); + } +} diff --git a/packages/react-native-builder-bob/src/index.ts b/packages/react-native-builder-bob/src/index.ts index 7019e2f2e..b2c12206c 100644 --- a/packages/react-native-builder-bob/src/index.ts +++ b/packages/react-native-builder-bob/src/index.ts @@ -1,18 +1,7 @@ -import path from 'path'; -import fs from 'fs-extra'; -import kleur from 'kleur'; -import dedent from 'dedent'; import yargs from 'yargs'; -import { cosmiconfig } from 'cosmiconfig'; -import isGitDirty from 'is-git-dirty'; -import prompts, { type PromptObject } from './utils/prompts'; -import * as logger from './utils/logger'; -import buildCommonJS from './targets/commonjs'; -import buildModule from './targets/module'; -import buildTypescript from './targets/typescript'; -import buildCodegen from './targets/codegen'; -import customTarget from './targets/custom'; -import type { Options, Target } from './types'; +import { build } from './build'; +import { init } from './init'; +import type { Target } from './types'; type ArgName = 'target'; @@ -24,562 +13,9 @@ const args = { }, } satisfies Record; -// eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-var-requires -const { name, version } = require('../package.json'); - -const root = process.cwd(); -const explorer = cosmiconfig(name, { - stopDir: root, - searchPlaces: [ - 'package.json', - 'bob.config.mjs', - 'bob.config.cjs', - 'bob.config.js', - ], -}); - -const projectPackagePath = path.resolve(root, 'package.json'); - -const FLOW_PRGAMA_REGEX = /\*?\s*@(flow)\b/m; - yargs - .command('init', 'configure the package to use bob', {}, async () => { - if (isGitDirty()) { - const { shouldContinue } = await prompts({ - type: 'confirm', - name: 'shouldContinue', - message: `The working directory is not clean.\n You should commit or stash your changes before configuring bob.\n Continue anyway?`, - initial: false, - }); - - if (!shouldContinue) { - process.exit(0); - } - } - - if (!(await fs.pathExists(projectPackagePath))) { - logger.error( - `Couldn't find a 'package.json' file in '${root}'.\n Are you in a project folder?` - ); - process.exit(1); - } - - const pkg = JSON.parse(await fs.readFile(projectPackagePath, 'utf-8')); - const result = await explorer.search(); - - if (result?.config && pkg.devDependencies && name in pkg.devDependencies) { - const { shouldContinue } = await prompts({ - type: 'confirm', - name: 'shouldContinue', - message: `The project seems to be already configured with bob.\n Do you want to overwrite the existing configuration?`, - initial: false, - }); - - if (!shouldContinue) { - process.exit(0); - } - } - - const { source } = await prompts({ - type: 'text', - name: 'source', - message: 'Where are your source files?', - initial: 'src', - validate: (input) => Boolean(input), - }); - - let entryFile; - - if (await fs.pathExists(path.join(root, source, 'index.js'))) { - entryFile = 'index.js'; - } else if (await fs.pathExists(path.join(root, source, 'index.ts'))) { - entryFile = 'index.ts'; - } else if (await fs.pathExists(path.join(root, source, 'index.tsx'))) { - entryFile = 'index.tsx'; - } - - if (!entryFile) { - logger.error( - `Couldn't find a 'index.js'. 'index.ts' or 'index.tsx' file under '${source}'.\n Please re-run the CLI after creating it.` - ); - process.exit(1); - } - - pkg.devDependencies = Object.fromEntries( - [ - ...Object.entries(pkg.devDependencies || {}), - [name, `^${version}`], - ].sort(([a], [b]) => a.localeCompare(b)) - ); - - const questions: PromptObject[] = [ - { - type: 'text', - name: 'output', - message: 'Where do you want to generate the output files?', - initial: 'lib', - validate: (input: string) => Boolean(input), - }, - { - type: 'multiselect', - name: 'targets', - message: 'Which targets do you want to build?', - choices: [ - { - title: 'commonjs - for running in Node (tests, SSR etc.)', - value: 'commonjs', - selected: true, - }, - { - title: 'module - for bundlers (metro, webpack etc.)', - value: 'module', - selected: true, - }, - { - title: 'typescript - declaration files for typechecking', - value: 'typescript', - selected: /\.tsx?$/.test(entryFile), - }, - ], - validate: (input: string) => Boolean(input.length), - }, - ]; - - if ( - entryFile.endsWith('.js') && - FLOW_PRGAMA_REGEX.test( - await fs.readFile(path.join(root, source, entryFile), 'utf-8') - ) - ) { - questions.push({ - type: 'confirm', - name: 'flow', - message: 'Do you want to publish definitions for flow?', - initial: Object.keys(pkg.devDependencies || {}).includes('flow-bin'), - }); - } - - const { output, targets, flow } = await prompts(questions); - - const target = - targets[0] === 'commonjs' || targets[0] === 'module' - ? targets[0] - : undefined; - - const entries: { - [key in 'source' | 'main' | 'module' | 'types']?: string; - } = { - source: `./${path.join(source, entryFile)}`, - }; - - let esm = false; - - if (targets.includes('module')) { - esm = true; - - if (targets.includes('commonjs')) { - entries.main = `./${path.join(output, 'commonjs', 'index.js')}`; - } - - entries.module = `./${path.join(output, 'module', 'index.js')}`; - } else if (targets.includes('commonjs')) { - entries.main = `./${path.join(output, 'commonjs', 'index.js')}`; - } else { - entries.main = entries.source; - } - - const types: { - [key in 'require' | 'import']?: string; - } = {}; - - if (targets.includes('typescript')) { - types.require = `./${path.join( - output, - 'typescript', - 'commonjs', - source, - 'index.d.ts' - )}`; - - types.import = `./${path.join( - output, - 'typescript', - 'module', - source, - 'index.d.ts' - )}`; - - entries.types = types.require; - - if (!(await fs.pathExists(path.join(root, 'tsconfig.json')))) { - const { tsconfig } = await prompts({ - type: 'confirm', - name: 'tsconfig', - message: `You have enabled 'typescript' compilation, but we couldn't find a 'tsconfig.json' in project root.\n Generate one?`, - initial: true, - }); - - if (tsconfig) { - await fs.writeJSON( - path.join(root, 'tsconfig.json'), - { - compilerOptions: { - rootDir: '.', - allowUnreachableCode: false, - allowUnusedLabels: false, - esModuleInterop: true, - forceConsistentCasingInFileNames: true, - jsx: 'react-jsx', - lib: ['ESNext'], - module: 'ESNext', - moduleResolution: 'Bundler', - noFallthroughCasesInSwitch: true, - noImplicitReturns: true, - noImplicitUseStrict: false, - noStrictGenericChecks: false, - noUncheckedIndexedAccess: true, - noUnusedLocals: true, - noUnusedParameters: true, - resolveJsonModule: true, - skipLibCheck: true, - strict: true, - target: 'ESNext', - verbatimModuleSyntax: true, - }, - }, - { spaces: 2 } - ); - } - } - } - - const prepare = 'bob build'; - const files = [ - source, - output, - '!**/__tests__', - '!**/__fixtures__', - '!**/__mocks__', - ]; - - for (const key in entries) { - const entry = entries[key as keyof typeof entries]; - - if (pkg[key] && pkg[key] !== entry) { - const { replace } = await prompts({ - type: 'confirm', - name: 'replace', - message: `Your package.json has the '${key}' field set to '${pkg[key]}'.\n Do you want to replace it with '${entry}'?`, - initial: true, - }); - - if (replace) { - pkg[key] = entry; - } - } else { - pkg[key] = entry; - } - } - - if (esm) { - let replace = false; - - const exportsField = { - '.': { - import: { - ...(types.import ? { types: types.import } : null), - ...(entries.module ? { default: entries.module } : null), - }, - require: { - ...(types.require ? { types: types.require } : null), - ...(entries.main ? { default: entries.main } : null), - }, - }, - }; - - if (pkg.codegenConfig && !pkg.codegenConfig.includesGeneratedCode) { - // @ts-expect-error The exports is not strictly types therefore it doesn't know about the package.json property - exportsField['./package.json'] = './package.json'; - } - - if ( - pkg.exports && - JSON.stringify(pkg.exports) !== JSON.stringify(exportsField) - ) { - replace = ( - await prompts({ - type: 'confirm', - name: 'replace', - message: `Your package.json has 'exports' field set.\n Do you want to replace it?`, - initial: true, - }) - ).replace; - } else { - replace = true; - } - - if (replace) { - pkg.exports = exportsField; - } - } - - if ( - pkg['react-native'] && - (pkg['react-native'].startsWith(source) || - pkg['react-native'].startsWith(`./${source}`)) - ) { - const { remove } = await prompts({ - type: 'confirm', - name: 'remove', - message: `Your package.json has the 'react-native' field pointing to source code.\n This can cause problems when customizing babel configuration.\n Do you want to remove it?`, - initial: true, - }); - - if (remove) { - delete pkg['react-native']; - } - } - - if (pkg.scripts?.prepare && pkg.scripts.prepare !== prepare) { - const { replace } = await prompts({ - type: 'confirm', - name: 'replace', - message: `Your package.json has the 'scripts.prepare' field set to '${pkg.scripts.prepare}'.\n Do you want to replace it with '${prepare}'?`, - initial: true, - }); - - if (replace) { - pkg.scripts.prepare = prepare; - } - } else { - pkg.scripts = pkg.scripts || {}; - pkg.scripts.prepare = prepare; - } - - if (pkg.files) { - const pkgFiles = pkg.files; - - if (files?.some((file) => !pkgFiles.includes(file))) { - const { update } = await prompts({ - type: 'confirm', - name: 'update', - message: `Your package.json already has a 'files' field.\n Do you want to update it?`, - initial: true, - }); - - if (update) { - pkg.files = [ - ...files, - ...pkg.files.filter((file: string) => !files.includes(file)), - ]; - } - } - } else { - pkg.files = files; - } - - pkg[name] = { - source, - output, - targets: targets.map((t: string) => { - if (t === target && flow) { - return [t, { copyFlow: true }]; - } - - if (t === 'commonjs' || t === 'module' || t === 'typescript') { - return [t, { esm }]; - } - - return t; - }), - }; - - if (pkg.jest) { - const entry = `/${output}/`; - - if (pkg.jest.modulePathIgnorePatterns) { - const { modulePathIgnorePatterns } = pkg.jest; - - if (!modulePathIgnorePatterns.includes(entry)) { - modulePathIgnorePatterns.push(entry); - } - } else { - pkg.jest.modulePathIgnorePatterns = [entry]; - } - } - - pkg.eslintIgnore = pkg.eslintIgnore || ['node_modules/']; - - if (!pkg.eslintIgnore.includes(`${output}/`)) { - pkg.eslintIgnore.push(`${output}/`); - } - - await fs.writeJSON(projectPackagePath, pkg, { - spaces: 2, - }); - - const ignorefiles = [ - path.join(root, '.gitignore'), - path.join(root, '.eslintignore'), - ]; - - for (const ignorefile of ignorefiles) { - if (await fs.pathExists(ignorefile)) { - const content = await fs.readFile(ignorefile, 'utf-8'); - - if (!content.split('\n').includes(`${output}/`)) { - await fs.writeFile( - ignorefile, - `${content}\n# generated by bob\n${output}/\n` - ); - } - } - } - - const packageManager = (await fs.pathExists(path.join(root, 'yarn.lock'))) - ? 'yarn' - : 'npm'; - - console.log( - dedent(` - Project ${kleur.yellow(pkg.name)} configured successfully! - - ${kleur.magenta( - `${kleur.bold('Perform last steps')} by running` - )}${kleur.gray(':')} - - ${kleur.gray('$')} ${packageManager} install - - ${kleur.yellow('Good luck!')} - `) - ); - }) - .command('build', 'build files for publishing', args, async (argv) => { - if (!(await fs.pathExists(projectPackagePath))) { - throw new Error( - `Couldn't find a 'package.json' file in '${root}'. Are you in a project folder?` - ); - } - - const result = await explorer.search(); - - if (!result?.config) { - logger.error( - `No configuration found. Run '${argv.$0} init' to create one automatically.` - ); - process.exit(1); - } - - const options: Options = result!.config; - - if (!options.targets?.length) { - logger.error( - `No targets found in the configuration in '${path.relative( - root, - result!.filepath - )}'.` - ); - process.exit(1); - } - - const source = options.source; - - if (!source) { - logger.error( - `No source option found in the configuration in '${path.relative( - root, - result!.filepath - )}'.` - ); - process.exit(1); - } - - const output = options.output; - - if (!output) { - logger.error( - `No source option found in the configuration in '${path.relative( - root, - result!.filepath - )}'.` - ); - process.exit(1); - } - - const exclude = - options.exclude ?? '**/{__tests__,__fixtures__,__mocks__}/**'; - - if (argv.target != null) { - buildTarget(argv.target, source as string, output as string, exclude); - } else { - for (const target of options.targets!) { - buildTarget(target, source as string, output as string, exclude); - } - } - }) + .command('init', 'configure the package to use bob', {}, init) + .command('build', 'build files for publishing', args, build) .demandCommand() .recommendCommands() .strict().argv; - -async function buildTarget( - target: Exclude[number], - source: string, - output: string, - exclude: string -) { - const targetName = Array.isArray(target) ? target[0] : target; - const targetOptions = Array.isArray(target) ? target[1] : undefined; - - const report = logger.grouped(targetName); - - switch (targetName) { - case 'commonjs': - await buildCommonJS({ - root, - source: path.resolve(root, source), - output: path.resolve(root, output, 'commonjs'), - exclude, - options: targetOptions, - report, - }); - break; - case 'module': - await buildModule({ - root, - source: path.resolve(root, source), - output: path.resolve(root, output, 'module'), - exclude, - options: targetOptions, - report, - }); - break; - case 'typescript': - await buildTypescript({ - root, - source: path.resolve(root, source), - output: path.resolve(root, output, 'typescript'), - options: targetOptions, - report, - }); - break; - case 'codegen': - await buildCodegen({ - root, - source: path.resolve(root, source), - output: path.resolve(root, output, 'typescript'), - report, - }); - break; - case 'custom': - await customTarget({ - options: targetOptions, - source: path.resolve(root, source), - report, - root, - }); - break; - default: - logger.error(`Invalid target ${kleur.blue(targetName)}.`); - process.exit(1); - } -} diff --git a/packages/react-native-builder-bob/src/init.ts b/packages/react-native-builder-bob/src/init.ts new file mode 100644 index 000000000..f7bab44ed --- /dev/null +++ b/packages/react-native-builder-bob/src/init.ts @@ -0,0 +1,466 @@ +import path from 'path'; +import fs from 'fs-extra'; +import kleur from 'kleur'; +import dedent from 'dedent'; +import isGitDirty from 'is-git-dirty'; +import prompts, { type PromptObject } from './utils/prompts'; +import * as logger from './utils/logger'; +import { loadConfig } from './utils/loadConfig'; + +// eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-var-requires +const { name, version } = require('../package.json'); + +const FLOW_PRGAMA_REGEX = /\*?\s*@(flow)\b/m; + +export async function init() { + const root = process.cwd(); + const projectPackagePath = path.resolve(root, 'package.json'); + + if (isGitDirty()) { + const { shouldContinue } = await prompts({ + type: 'confirm', + name: 'shouldContinue', + message: `The working directory is not clean.\n You should commit or stash your changes before configuring bob.\n Continue anyway?`, + initial: false, + }); + + if (!shouldContinue) { + process.exit(0); + } + } + + if (!(await fs.pathExists(projectPackagePath))) { + logger.error( + `Couldn't find a 'package.json' file in '${root}'.\n Are you in a project folder?` + ); + process.exit(1); + } + + const pkg = JSON.parse(await fs.readFile(projectPackagePath, 'utf-8')); + const result = await loadConfig(); + + if (result?.config && pkg.devDependencies && name in pkg.devDependencies) { + const { shouldContinue } = await prompts({ + type: 'confirm', + name: 'shouldContinue', + message: `The project seems to be already configured with bob.\n Do you want to overwrite the existing configuration?`, + initial: false, + }); + + if (!shouldContinue) { + process.exit(0); + } + } + + const { source } = await prompts({ + type: 'text', + name: 'source', + message: 'Where are your source files?', + initial: 'src', + validate: (input) => Boolean(input), + }); + + let entryFile; + + if (await fs.pathExists(path.join(root, source, 'index.js'))) { + entryFile = 'index.js'; + } else if (await fs.pathExists(path.join(root, source, 'index.ts'))) { + entryFile = 'index.ts'; + } else if (await fs.pathExists(path.join(root, source, 'index.tsx'))) { + entryFile = 'index.tsx'; + } + + if (!entryFile) { + logger.error( + `Couldn't find a 'index.js'. 'index.ts' or 'index.tsx' file under '${source}'.\n Please re-run the CLI after creating it.` + ); + process.exit(1); + } + + pkg.devDependencies = Object.fromEntries( + [...Object.entries(pkg.devDependencies || {}), [name, `^${version}`]].sort( + ([a], [b]) => a.localeCompare(b) + ) + ); + + const questions: PromptObject[] = [ + { + type: 'text', + name: 'output', + message: 'Where do you want to generate the output files?', + initial: 'lib', + validate: (input: string) => Boolean(input), + }, + { + type: 'multiselect', + name: 'targets', + message: 'Which targets do you want to build?', + choices: [ + { + title: 'module - for modern setups', + value: 'module', + selected: true, + }, + { + title: 'commonjs - for legacy setups (Node.js < 20)', + value: 'commonjs', + selected: true, + }, + { + title: 'typescript - declaration files for typechecking', + value: 'typescript', + selected: /\.tsx?$/.test(entryFile), + }, + ], + validate: (input: string) => Boolean(input.length), + }, + ]; + + if ( + entryFile.endsWith('.js') && + FLOW_PRGAMA_REGEX.test( + await fs.readFile(path.join(root, source, entryFile), 'utf-8') + ) + ) { + questions.push({ + type: 'confirm', + name: 'flow', + message: 'Do you want to publish definitions for flow?', + initial: Object.keys(pkg.devDependencies || {}).includes('flow-bin'), + }); + } + + const { output, targets, flow } = await prompts(questions); + + const target = + targets[0] === 'commonjs' || targets[0] === 'module' + ? targets[0] + : undefined; + + const entries: { + [key in 'source' | 'commonjs' | 'module']?: string; + } = { + source: `./${path.join(source, entryFile)}`, + }; + + let esm = false; + + if (targets.includes('module')) { + esm = true; + entries.module = `./${path.join(output, 'module', 'index.js')}`; + } + + if (targets.includes('commonjs')) { + entries.commonjs = `./${path.join(output, 'commonjs', 'index.js')}`; + } + + const types: { + [key in 'require' | 'import']?: string; + } = {}; + + if (targets.includes('typescript')) { + if (targets.includes('commonjs') && targets.includes('module')) { + types.require = `./${path.join( + output, + 'typescript', + 'commonjs', + source, + 'index.d.ts' + )}`; + + types.import = `./${path.join( + output, + 'typescript', + 'module', + source, + 'index.d.ts' + )}`; + } else { + types.require = `./${path.join( + output, + 'typescript', + source, + 'index.d.ts' + )}`; + + types.import = types.require; + } + + if (!(await fs.pathExists(path.join(root, 'tsconfig.json')))) { + const { tsconfig } = await prompts({ + type: 'confirm', + name: 'tsconfig', + message: `You have enabled 'typescript' compilation, but we couldn't find a 'tsconfig.json' in project root.\n Generate one?`, + initial: true, + }); + + if (tsconfig) { + await fs.writeJSON( + path.join(root, 'tsconfig.json'), + { + compilerOptions: { + rootDir: '.', + allowUnreachableCode: false, + allowUnusedLabels: false, + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + jsx: 'react-jsx', + lib: ['ESNext'], + module: 'ESNext', + moduleResolution: 'bundler', + noFallthroughCasesInSwitch: true, + noImplicitReturns: true, + noImplicitUseStrict: false, + noStrictGenericChecks: false, + noUncheckedIndexedAccess: true, + noUnusedLocals: true, + noUnusedParameters: true, + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + target: 'ESNext', + verbatimModuleSyntax: true, + }, + }, + { spaces: 2 } + ); + } + } + } + + const prepare = 'bob build'; + const files = [ + source, + output, + '!**/__tests__', + '!**/__fixtures__', + '!**/__mocks__', + ]; + + if (esm) { + let replace = false; + + const exportsField = { + '.': {}, + }; + + const importField = { + ...(types.import ? { types: types.import } : null), + ...(entries.module ? { default: entries.module } : null), + }; + + const requireField = { + ...(types.require ? { types: types.require } : null), + ...(entries.commonjs ? { default: entries.commonjs } : null), + }; + + if (targets.includes('commonjs') && targets.includes('module')) { + exportsField['.'] = { + import: importField, + require: requireField, + }; + } else if (targets.includes('commonjs')) { + exportsField['.'] = requireField; + } else if (targets.includes('module')) { + exportsField['.'] = importField; + } + + if (pkg.codegenConfig && !pkg.codegenConfig.includesGeneratedCode) { + // @ts-expect-error The exports is not strictly types therefore it doesn't know about the package.json property + exportsField['./package.json'] = './package.json'; + } + + if ( + pkg.exports && + JSON.stringify(pkg.exports) !== JSON.stringify(exportsField) + ) { + replace = ( + await prompts({ + type: 'confirm', + name: 'replace', + message: `Your package.json has 'exports' field set.\n Do you want to replace it?`, + initial: true, + }) + ).replace; + } else { + replace = true; + } + + if (replace) { + pkg.exports = exportsField; + } + } + + const entryFields: { + [key in 'source' | 'main' | 'module' | 'types']?: string; + } = { + source: entries.source, + }; + + if (targets.includes('commonjs') && targets.includes('module')) { + entryFields.main = entries.commonjs; + entryFields.module = entries.module; + } else if (targets.includes('commonjs')) { + entryFields.main = entries.commonjs; + } else if (targets.includes('module')) { + entryFields.main = entries.module; + } + + if (targets.includes('typescript') && !pkg.exports?.['.']) { + if (entryFields.main === entries.commonjs) { + entryFields.types = types.require; + } else { + entryFields.types = types.import; + } + } + + for (const key in entryFields) { + const entry = entryFields[key as keyof typeof entryFields]; + + if (pkg[key] && pkg[key] !== entry) { + const { replace } = await prompts({ + type: 'confirm', + name: 'replace', + message: `Your package.json has the '${key}' field set to '${pkg[key]}'.\n Do you want to replace it with '${entry}'?`, + initial: true, + }); + + if (replace) { + pkg[key] = entry; + } + } else { + pkg[key] = entry; + } + } + + if ( + pkg['react-native'] && + (pkg['react-native'].startsWith(source) || + pkg['react-native'].startsWith(`./${source}`)) + ) { + const { remove } = await prompts({ + type: 'confirm', + name: 'remove', + message: `Your package.json has the 'react-native' field pointing to source code.\n This can cause problems when customizing babel configuration.\n Do you want to remove it?`, + initial: true, + }); + + if (remove) { + delete pkg['react-native']; + } + } + + if (pkg.scripts?.prepare && pkg.scripts.prepare !== prepare) { + const { replace } = await prompts({ + type: 'confirm', + name: 'replace', + message: `Your package.json has the 'scripts.prepare' field set to '${pkg.scripts.prepare}'.\n Do you want to replace it with '${prepare}'?`, + initial: true, + }); + + if (replace) { + pkg.scripts.prepare = prepare; + } + } else { + pkg.scripts = pkg.scripts || {}; + pkg.scripts.prepare = prepare; + } + + if (pkg.files) { + const pkgFiles = pkg.files; + + if (files?.some((file) => !pkgFiles.includes(file))) { + const { update } = await prompts({ + type: 'confirm', + name: 'update', + message: `Your package.json already has a 'files' field.\n Do you want to update it?`, + initial: true, + }); + + if (update) { + pkg.files = [ + ...files, + ...pkg.files.filter((file: string) => !files.includes(file)), + ]; + } + } + } else { + pkg.files = files; + } + + pkg[name] = { + source, + output, + targets: targets.map((t: string) => { + if (t === target && flow) { + return [t, { copyFlow: true }]; + } + + if (t === 'commonjs' || t === 'module') { + return [t, { esm }]; + } + + return t; + }), + }; + + if (pkg.jest) { + const entry = `/${output}/`; + + if (pkg.jest.modulePathIgnorePatterns) { + const { modulePathIgnorePatterns } = pkg.jest; + + if (!modulePathIgnorePatterns.includes(entry)) { + modulePathIgnorePatterns.push(entry); + } + } else { + pkg.jest.modulePathIgnorePatterns = [entry]; + } + } + + pkg.eslintIgnore = pkg.eslintIgnore || ['node_modules/']; + + if (!pkg.eslintIgnore.includes(`${output}/`)) { + pkg.eslintIgnore.push(`${output}/`); + } + + await fs.writeJSON(projectPackagePath, pkg, { + spaces: 2, + }); + + const ignorefiles = [ + path.join(root, '.gitignore'), + path.join(root, '.eslintignore'), + ]; + + for (const ignorefile of ignorefiles) { + if (await fs.pathExists(ignorefile)) { + const content = await fs.readFile(ignorefile, 'utf-8'); + + if (!content.split('\n').includes(`${output}/`)) { + await fs.writeFile( + ignorefile, + `${content}\n# generated by bob\n${output}/\n` + ); + } + } + } + + const packageManager = (await fs.pathExists(path.join(root, 'yarn.lock'))) + ? 'yarn' + : 'npm'; + + process.stdout.write( + dedent(` + Project ${kleur.yellow(pkg.name)} configured successfully! + + ${kleur.magenta( + `${kleur.bold('Perform last steps')} by running` + )}${kleur.gray(':')} + + ${kleur.gray('$')} ${packageManager} install + + ${kleur.yellow('Good luck!')} + `) + ); +} diff --git a/packages/react-native-builder-bob/src/targets/commonjs.ts b/packages/react-native-builder-bob/src/targets/commonjs.ts index b38875e5a..e333261d1 100644 --- a/packages/react-native-builder-bob/src/targets/commonjs.ts +++ b/packages/react-native-builder-bob/src/targets/commonjs.ts @@ -1,17 +1,12 @@ import path from 'path'; import kleur from 'kleur'; import del from 'del'; -import compile from '../utils/compile'; -import type { Input } from '../types'; +import compile, { type CompileOptions } from '../utils/compile'; +import type { Input, Variants } from '../types'; type Options = Input & { - options?: { - esm?: boolean; - babelrc?: boolean | null; - configFile?: string | false | null; - sourceMaps?: boolean; - copyFlow?: boolean; - }; + options?: CompileOptions; + variants: Variants; exclude: string; }; @@ -21,6 +16,7 @@ export default async function build({ output, exclude, options, + variants, report, }: Options) { report.info( @@ -31,6 +27,7 @@ export default async function build({ await compile({ ...options, + variants, root, source, output, diff --git a/packages/react-native-builder-bob/src/targets/module.ts b/packages/react-native-builder-bob/src/targets/module.ts index ff8cb0716..780a015fa 100644 --- a/packages/react-native-builder-bob/src/targets/module.ts +++ b/packages/react-native-builder-bob/src/targets/module.ts @@ -1,17 +1,12 @@ import path from 'path'; import kleur from 'kleur'; import del from 'del'; -import compile from '../utils/compile'; -import type { Input } from '../types'; +import compile, { type CompileOptions } from '../utils/compile'; +import type { Input, Variants } from '../types'; type Options = Input & { - options?: { - esm?: boolean; - babelrc?: boolean | null; - configFile?: string | false | null; - sourceMaps?: boolean; - copyFlow?: boolean; - }; + options?: CompileOptions; + variants: Variants; exclude: string; }; @@ -21,6 +16,7 @@ export default async function build({ output, exclude, options, + variants, report, }: Options) { report.info( @@ -31,6 +27,7 @@ export default async function build({ await compile({ ...options, + variants, root, source, output, diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 2d6189cd3..e858019d8 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -6,14 +6,15 @@ import spawn from 'cross-spawn'; import del from 'del'; import JSON5 from 'json5'; import { platform } from 'os'; -import type { Input } from '../types'; +import type { Input, Variants } from '../types'; type Options = Input & { options?: { - esm?: boolean; project?: string; tsc?: string; }; + esm: boolean; + variants: Variants; }; type Field = { @@ -21,6 +22,7 @@ type Field = { value: string | undefined; output: string | undefined; error: boolean; + message: string | undefined; }; export default async function build({ @@ -29,6 +31,8 @@ export default async function build({ output, report, options, + variants, + esm, }: Options) { report.info( `Cleaning up previous build at ${kleur.blue(path.relative(root, output))}` @@ -156,15 +160,25 @@ export default async function build({ ); } - const outputs = options?.esm - ? { - commonjs: path.join(output, 'commonjs'), - module: path.join(output, 'module'), - } - : { commonjs: output }; + const outputs: { commonjs?: string; module?: string } = {}; + + if (esm && variants.commonjs && variants.module) { + outputs.commonjs = path.join(output, 'commonjs'); + outputs.module = path.join(output, 'module'); + } else if (variants.commonjs) { + outputs.commonjs = output; + } else { + outputs.module = output; + } + + const outDir = outputs.commonjs ?? outputs.module; + + if (outDir == null) { + throw new Error('Neither commonjs nor module output is enabled.'); + } const tsbuildinfo = path.join( - outputs.commonjs, + outDir, project.replace(/\.json$/, '.tsbuildinfo') ); @@ -186,7 +200,7 @@ export default async function build({ '--project', project, '--outDir', - outputs.commonjs, + outDir, ], { stdio: 'inherit', @@ -197,16 +211,26 @@ export default async function build({ if (result.status === 0) { await del([tsbuildinfo]); - if (outputs?.module) { - // When ESM compatible output is enabled, we need to generate 2 builds for commonjs and esm - // In this case we copy the already generated types, and add `package.json` with `type` field - await fs.copy(outputs.commonjs, outputs.module); - await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { - type: 'commonjs', - }); - await fs.writeJSON(path.join(outputs.module, 'package.json'), { - type: 'module', - }); + if (esm) { + if (outputs?.commonjs && outputs?.module) { + // When ESM compatible output is enabled and commonjs build is present, we need to generate 2 builds for commonjs and esm + // In this case we copy the already generated types, and add `package.json` with `type` field + await fs.copy(outputs.commonjs, outputs.module); + await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { + type: 'commonjs', + }); + await fs.writeJSON(path.join(outputs.module, 'package.json'), { + type: 'module', + }); + } else if (outputs?.commonjs) { + await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { + type: 'commonjs', + }); + } else if (outputs?.module) { + await fs.writeJSON(path.join(outputs.module, 'package.json'), { + type: 'module', + }); + } } report.success( @@ -223,14 +247,22 @@ export default async function build({ value: pkg.types, output: outputs.commonjs, error: false, + message: undefined, }, ...(pkg.exports?.['.']?.types ? [ { name: "exports['.'].types", value: pkg.exports?.['.']?.types, - output: outputs.commonjs, - error: options?.esm === true, + output: outDir, + error: Boolean(esm && variants.commonjs && variants.module), + message: `using both ${kleur.blue('commonjs')} and ${kleur.blue( + 'module' + )} targets with ${kleur.blue( + 'esm' + )} option enabled. Specify ${kleur.blue( + "exports['.'].import.types" + )} and ${kleur.blue("exports['.'].require.types")} instead.`, }, ] : []), @@ -238,13 +270,17 @@ export default async function build({ name: "exports['.'].import.types", value: pkg.exports?.['.']?.import?.types, output: outputs.module, - error: !options?.esm, + error: !esm, + message: `the ${kleur.blue( + 'esm' + )} option is not enabled for the ${kleur.blue('module')} target`, }, { name: "exports['.'].require.types", value: pkg.exports?.['.']?.require?.types, output: outputs.commonjs, - error: !options?.esm, + error: false, + message: undefined, }, ]; @@ -284,9 +320,7 @@ export default async function build({ report.warn( `The ${kleur.blue(field.name)} field in ${kleur.blue( `package.json` - )} should not be set when the ${kleur.blue( - 'esm' - )} option is ${options?.esm ? 'enabled' : 'disabled'}.` + )} should not be set when ${field.message}.` ); } diff --git a/packages/react-native-builder-bob/src/types.ts b/packages/react-native-builder-bob/src/types.ts index 4a6ac6d8a..80ac02857 100644 --- a/packages/react-native-builder-bob/src/types.ts +++ b/packages/react-native-builder-bob/src/types.ts @@ -27,6 +27,11 @@ export type Options = { exclude?: string; }; +export type Variants = { + commonjs?: boolean; + module?: boolean; +}; + declare module '@babel/core' { export interface TransformCaller { rewriteImportExtensions: boolean; diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index 5b4a0b90b..cd1b3474e 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -3,20 +3,25 @@ import fs from 'fs-extra'; import kleur from 'kleur'; import * as babel from '@babel/core'; import glob from 'glob'; -import type { Input } from '../types'; +import type { Input, Variants } from '../types'; import { isCodegenSpec } from './isCodegenSpec'; -type Options = Input & { +export type CompileOptions = { esm?: boolean; babelrc?: boolean | null; configFile?: string | false | null; sourceMaps?: boolean; copyFlow?: boolean; - modules: 'commonjs' | 'preserve'; - exclude: string; jsxRuntime?: 'automatic' | 'classic'; }; +type Options = Input & + CompileOptions & { + modules: 'commonjs' | 'preserve'; + variants: Variants; + exclude: string; + }; + const sourceExt = /\.([cm])?[jt]sx?$/; export default async function compile({ @@ -32,6 +37,7 @@ export default async function compile({ sourceMaps = true, report, jsxRuntime = 'automatic', + variants, }: Options) { const files = glob.sync('**/*', { cwd: source, @@ -182,36 +188,50 @@ export default async function compile({ return null; }; - const fields = - modules === 'commonjs' - ? [{ name: 'main', value: pkg.main }] - : [{ name: 'module', value: pkg.module }]; + const fields: { name: string; value: string | undefined }[] = []; - if (esm) { + if (variants.commonjs && variants.module) { if (modules === 'commonjs') { - fields.push( - typeof pkg.exports?.['.']?.require === 'string' - ? { - name: "exports['.'].require", - value: pkg.exports?.['.']?.require, - } - : { - name: "exports['.'].require.default", - value: pkg.exports?.['.']?.require?.default, - } - ); + fields.push({ name: 'main', value: pkg.main }); } else { - fields.push( - typeof pkg.exports?.['.']?.import === 'string' - ? { - name: "exports['.'].import", - value: pkg.exports?.['.']?.import, - } - : { - name: "exports['.'].import.default", - value: pkg.exports?.['.']?.import?.default, - } - ); + fields.push({ name: 'module', value: pkg.module }); + } + } else { + fields.push({ name: 'main', value: pkg.main }); + } + + if (esm) { + if (variants.commonjs && variants.module) { + if (modules === 'commonjs') { + fields.push( + typeof pkg.exports?.['.']?.require === 'string' + ? { + name: "exports['.'].require", + value: pkg.exports?.['.']?.require, + } + : { + name: "exports['.'].require.default", + value: pkg.exports?.['.']?.require?.default, + } + ); + } else { + fields.push( + typeof pkg.exports?.['.']?.import === 'string' + ? { + name: "exports['.'].import", + value: pkg.exports?.['.']?.import, + } + : { + name: "exports['.'].import.default", + value: pkg.exports?.['.']?.import?.default, + } + ); + } + } else { + fields.push({ + name: "exports['.'].default", + value: pkg.exports?.['.']?.default, + }); } } else { if (modules === 'commonjs' && pkg.exports?.['.']?.require) { @@ -233,6 +253,8 @@ export default async function compile({ } } + const generatedEntryPath = await getGeneratedEntryPath(); + if (fields.some((field) => field.value)) { await Promise.all( fields.map(async ({ name, value }) => { @@ -261,8 +283,6 @@ export default async function compile({ 'code' in e && e.code === 'MODULE_NOT_FOUND' ) { - const generatedEntryPath = await getGeneratedEntryPath(); - if (!generatedEntryPath) { report.warn( `Failed to detect the entry point for the generated files. Make sure you have a valid ${kleur.blue( @@ -292,9 +312,27 @@ export default async function compile({ } }) ); - } else { - const generatedEntryPath = await getGeneratedEntryPath(); + if ( + modules === 'commonjs' && + pkg.exports?.['.']?.import === `./${generatedEntryPath}` + ) { + report.warn( + `The the ${kleur.blue( + "exports['.'].import" + )} field points to a CommonJS module. This is likely a mistake.` + ); + } else if ( + modules === 'preserve' && + pkg.exports?.['.']?.require === `./${generatedEntryPath}` + ) { + report.warn( + `The the ${kleur.blue( + "exports['.'].import" + )} field points to a ES module. This is likely a mistake.` + ); + } + } else { report.warn( `No ${fields .map((field) => kleur.blue(field.name)) diff --git a/packages/react-native-builder-bob/src/utils/loadConfig.ts b/packages/react-native-builder-bob/src/utils/loadConfig.ts new file mode 100644 index 000000000..369da9849 --- /dev/null +++ b/packages/react-native-builder-bob/src/utils/loadConfig.ts @@ -0,0 +1,19 @@ +import { cosmiconfig } from 'cosmiconfig'; + +// eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-var-requires +const { name } = require('../../package.json'); + +const root = process.cwd(); +const explorer = cosmiconfig(name, { + stopDir: root, + searchPlaces: [ + 'package.json', + 'bob.config.mjs', + 'bob.config.cjs', + 'bob.config.js', + ], +}); + +export const loadConfig = async () => { + return explorer.search(); +}; diff --git a/yarn.lock b/yarn.lock index 25bc214ee..dec43b0d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4689,9 +4689,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001406, caniuse-lite@npm:^1.0.30001640": - version: 1.0.30001645 - resolution: "caniuse-lite@npm:1.0.30001645" - checksum: a4808bac31fdcdf183ce12f8c86d101e515b2df3423ae4284b930b493809ae88b3396b52ca2a197a3de3c94046ee5384cc9f0efeff5ccfb7c8cd385229527596 + version: 1.0.30001707 + resolution: "caniuse-lite@npm:1.0.30001707" + checksum: 38824c9f88d754428844e64ba18197c06f4f8503035e30eace88c6bffdcf5f682dcf3cef895b60cd6f19c71e6714731adc1940b612ea606c6875cd2f801e4836 languageName: node linkType: hard @@ -11635,6 +11635,13 @@ __metadata: languageName: node linkType: hard +"mock-stdin@npm:^1.0.0": + version: 1.0.0 + resolution: "mock-stdin@npm:1.0.0" + checksum: da038fae5cc189915e4865cbcb0a0b71e98a7e8569959423ac6e798d5e626bc12708c9918d98c46a5aab35d8d8f8bfa782dce25532173137a69fc162990eda91 + languageName: node + linkType: hard + "modify-values@npm:^1.0.0": version: 1.0.1 resolution: "modify-values@npm:1.0.1" @@ -13036,6 +13043,7 @@ __metadata: kleur: ^4.1.4 metro-config: ^0.80.9 mock-fs: ^5.2.0 + mock-stdin: ^1.0.0 prompts: ^2.4.2 which: ^2.0.2 yargs: ^17.5.1