From 012497a85475862088173af82c0f2299872f88da Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 30 Jul 2024 11:10:25 +0200 Subject: [PATCH] feat: add esm build option for typescript --- docs/pages/build.md | 21 ++- docs/pages/esm.md | 18 +- .../create-react-native-library/src/index.ts | 2 +- .../templates/common/$package.json | 15 +- .../react-native-builder-bob/src/index.ts | 64 ++++--- .../src/targets/typescript.ts | 175 ++++++++++++++---- .../src/utils/compile.ts | 54 ++++-- 7 files changed, 258 insertions(+), 91 deletions(-) diff --git a/docs/pages/build.md b/docs/pages/build.md index 6338e192f..d5705f363 100644 --- a/docs/pages/build.md +++ b/docs/pages/build.md @@ -44,7 +44,7 @@ yarn add --dev react-native-builder-bob "targets": [ ["commonjs", { "esm": true }], ["module", { "esm": true }], - "typescript", + ["typescript", { "esm": true }] ] } ``` @@ -76,12 +76,17 @@ yarn add --dev react-native-builder-bob "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", "module": "./lib/module/index.js", - "types": "./lib/typescript/src/index.d.ts", + "types": "./lib/typescript/commonjs/src/index.d.ts", "exports": { ".": { - "types": "./typescript/src/index.d.ts", - "import": "./module/index.js", - "require": "./commonjs/index.js" + "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" + } } }, "files": [ @@ -224,6 +229,12 @@ Example: The output file should be referenced in the `types` field or `exports['.'].types` field of `package.json`. +##### `esm` + +Setting this option to `true` will output 2 sets of type definitions: one for the CommonJS build and one for the ES module build. + +See the [ESM support](./esm.md) guide for more details. + ## Commands The `bob` CLI exposes the following commands: diff --git a/docs/pages/esm.md b/docs/pages/esm.md index 23c65acb3..9a8ce38a3 100644 --- a/docs/pages/esm.md +++ b/docs/pages/esm.md @@ -11,12 +11,12 @@ You can verify whether ESM support is enabled by checking the configuration for "targets": [ ["commonjs", { "esm": true }], ["module", { "esm": true }], - "typescript", + ["typescript", { "esm": true }] ] } ``` -The `"esm": true` option enables ESM-compatible output by adding the `.js` extension to the import statements in the generated files. +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"` in your `tsconfig.json` file as well: @@ -43,10 +43,16 @@ There are still a few things to keep in mind if you want your library to be ESM- ```json "exports": { ".": { - "types": "./lib/typescript/src/index.d.ts", - "react-native": "./lib/modules/index.native.js", - "import": "./lib/modules/index.js", - "require": "./lib/commonjs/index.js" + "import": { + "types": "./lib/typescript/module/src/index.d.ts", + "react-native": "./lib/modules/index.native.js", + "default": "./lib/module/index.js" + }, + "require": { + "types": "./lib/typescript/commonjs/src/index.d.ts", + "react-native": "./lib/commonjs/index.native.js", + "default": "./lib/commonjs/index.js" + } } } ``` diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index 52be4e38d..9ce6616b2 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -14,7 +14,7 @@ import generateExampleApp, { import { spawn } from './utils/spawn'; import { version } from '../package.json'; -const FALLBACK_BOB_VERSION = '0.28.0'; +const FALLBACK_BOB_VERSION = '0.29.0'; const BINARIES = [ /(gradlew|\.(jar|keystore|png|jpg|gif))$/, diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index 8315a7c0b..4dea8f8b9 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -5,12 +5,16 @@ "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", "module": "./lib/module/index.js", - "types": "./lib/typescript/src/index.d.ts", "exports": { ".": { - "types": "./lib/typescript/src/index.d.ts", - "import": "./lib/module/index.js", - "require": "./lib/commonjs/index.js" + "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" + } } }, "files": [ @@ -178,7 +182,8 @@ [ "typescript", { - "project": "tsconfig.build.json" + "project": "tsconfig.build.json", + "esm": true } ] ] diff --git a/packages/react-native-builder-bob/src/index.ts b/packages/react-native-builder-bob/src/index.ts index 19a68e198..26bc8c96c 100644 --- a/packages/react-native-builder-bob/src/index.ts +++ b/packages/react-native-builder-bob/src/index.ts @@ -175,14 +175,29 @@ yargs entries.main = entries.source; } + const types: { + [key in 'require' | 'import']?: string; + } = {}; + if (targets.includes('typescript')) { - entries.types = `./${path.join( + 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', @@ -258,9 +273,14 @@ yargs const exports = { '.': { - ...(entries.types ? { types: entries.types } : null), - ...(entries.module ? { import: entries.module } : null), - ...(entries.main ? { require: entries.main } : null), + 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), + }, }, }; @@ -318,25 +338,23 @@ yargs pkg.scripts.prepare = prepare; } - if ( - pkg.files && - JSON.stringify(pkg.files.slice().sort()) !== - JSON.stringify(files.slice().sort()) - ) { - 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 (pkg.files) { + const pkgFiles = pkg.files; - if (update) { - pkg.files = [ - ...files, - ...pkg.files.filter( - (file: string) => !files.includes(file.replace(/\/$/g, '')) - ), - ]; + 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; @@ -350,7 +368,7 @@ yargs return [t, { copyFlow: true }]; } - if (t === 'commonjs' || t === 'module') { + if (t === 'commonjs' || t === 'module' || t === 'typescript') { return [t, { esm }]; } diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 5cef25777..b333ab12d 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -9,7 +9,18 @@ import { platform } from 'os'; import type { Input } from '../types'; type Options = Input & { - options?: { project?: string; tsc?: string }; + options?: { + esm?: boolean; + project?: string; + tsc?: string; + }; +}; + +type Field = { + name: string; + value: string | undefined; + output: string | undefined; + error: boolean; }; export default async function build({ @@ -156,6 +167,13 @@ export default async function build({ // Ignore } + const outputs = options?.esm + ? { + commonjs: path.join(output, 'commonjs'), + module: path.join(output, 'module'), + } + : { commonjs: output }; + const result = spawn.sync( tsc, [ @@ -168,7 +186,7 @@ export default async function build({ '--project', project, '--outDir', - output, + outputs.commonjs, ], { stdio: 'inherit', @@ -179,6 +197,18 @@ 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', + }); + } + report.success( `Wrote definition files to ${kleur.blue(path.relative(root, output))}` ); @@ -187,16 +217,51 @@ export default async function build({ await fs.readFile(path.join(root, 'package.json'), 'utf-8') ); - const getGeneratedTypesPath = async () => { + const fields: Field[] = [ + { + name: 'types', + value: pkg.types, + output: outputs.commonjs, + error: false, + }, + ...(pkg.exports?.['.']?.types + ? [ + { + name: "exports['.'].types", + value: pkg.exports?.['.']?.types, + output: outputs.commonjs, + error: options?.esm === true, + }, + ] + : []), + { + name: "exports['.'].import.types", + value: pkg.exports?.['.']?.import?.types, + output: outputs.module, + error: !options?.esm, + }, + { + name: "exports['.'].require.types", + value: pkg.exports?.['.']?.require?.types, + output: outputs.commonjs, + error: !options?.esm, + }, + ]; + + const getGeneratedTypesPath = async (field: Field) => { + if (!field.output || field.error) { + return null; + } + if (pkg.source) { const indexDTsName = path.basename(pkg.source).replace(/\.(jsx?|tsx?)$/, '') + '.d.ts'; const potentialPaths = [ - path.join(output, path.dirname(pkg.source), indexDTsName), + path.join(field.output, path.dirname(pkg.source), indexDTsName), path.join( - output, - path.dirname(path.relative(source, path.join(root, pkg.source))), + field.output, + path.relative(source, path.join(root, path.dirname(pkg.source))), indexDTsName ), ]; @@ -211,36 +276,50 @@ export default async function build({ return null; }; - const fields = [ - { name: 'types', value: pkg.types }, - { name: "exports['.'].types", value: pkg.exports?.['.']?.types }, - ]; - - if (fields.some((field) => field.value)) { + const invalidFieldNames = ( await Promise.all( - fields.map(async ({ name, value }) => { - if (!value) { - return; + fields.map(async (field) => { + if (field.error) { + if (field.value) { + 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'}.` + ); + } + + return null; } - const typesPath = path.join(root, value); + if ( + field.name.startsWith('exports') && + field.value && + !/^\.\//.test(field.value) + ) { + report.error( + `The ${kleur.blue(field.name)} field in ${kleur.blue( + `package.json` + )} should be a relative path starting with ${kleur.blue( + './' + )}. Found: ${kleur.blue(field.value)}` + ); - if (!(await fs.pathExists(typesPath))) { - const generatedTypesPath = await getGeneratedTypesPath(); + return field.name; + } - if (!generatedTypesPath) { - report.warn( - `Failed to detect the entry point for the generated types. Make sure you have a valid ${kleur.blue( - 'source' - )} field in your ${kleur.blue('package.json')}.` - ); - } + if ( + field.value && + !(await fs.pathExists(path.join(root, field.value))) + ) { + const generatedTypesPath = await getGeneratedTypesPath(field); report.error( - `The ${kleur.blue(name)} field in ${kleur.blue( + `The ${kleur.blue(field.name)} field in ${kleur.blue( 'package.json' )} points to a non-existent file: ${kleur.blue( - value + field.value )}.\nVerify the path points to the correct file under ${kleur.blue( path.relative(root, output) )}${ @@ -250,21 +329,43 @@ export default async function build({ }` ); - throw new Error(`Found incorrect path in '${name}' field.`); + return field.name; } + + return null; }) + ) + ).filter((name): name is string => name != null); + + if (invalidFieldNames.length) { + throw new Error( + `Found errors for fields: ${invalidFieldNames.join(', ')}.` ); - } else { - const generatedTypesPath = await getGeneratedTypesPath(); + } + + const validFields = fields.filter((field) => !field.error); + + if (validFields.every((field) => field.value == null)) { + const suggestedTypesPaths = ( + await Promise.all( + validFields.map((field) => getGeneratedTypesPath(field)) + ) + ) + .filter((path): path is string => path != null) + .filter((path, i, self) => self.indexOf(path) === i); report.warn( - `No ${kleur.blue( - fields.map((field) => field.name).join(' or ') - )} field found in ${kleur.blue('package.json')}.\nConsider ${ - generatedTypesPath - ? `pointing it to ${kleur.blue(generatedTypesPath)}` - : 'adding it' - } so that consumers of your package can use the types.` + `No ${validFields + .map((field) => kleur.blue(field.name)) + .join(' or ')} field found in ${kleur.blue( + 'package.json' + )}. Consider ${ + suggestedTypesPaths.length + ? `pointing to ${suggestedTypesPaths + .map((path) => kleur.blue(path)) + .join(' or ')}` + : `adding ${validFields.length > 1 ? 'them' : 'it'}` + } so that consumers of your package can use the typescript definitions.` ); } } else { diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index a3401fc1e..b0a6b8b64 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -168,15 +168,29 @@ export default async function compile({ if (esm) { if (modules === 'commonjs') { - fields.push({ - name: "exports['.'].require", - value: pkg.exports?.['.']?.require, - }); + 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({ - name: "exports['.'].import", - value: pkg.exports?.['.']?.import, - }); + fields.push( + typeof pkg.exports?.['.']?.import === 'string' + ? { + name: "exports['.'].import", + value: pkg.exports?.['.']?.import, + } + : { + name: "exports['.'].import.default", + value: pkg.exports?.['.']?.import?.default, + } + ); } } else { if (modules === 'commonjs' && pkg.exports?.['.']?.require) { @@ -205,6 +219,18 @@ export default async function compile({ return; } + if (name.startsWith('exports') && value && !/^\.\//.test(value)) { + report.error( + `The ${kleur.blue(name)} field in ${kleur.blue( + `package.json` + )} should be a relative path starting with ${kleur.blue( + './' + )}. Found: ${kleur.blue(value)}` + ); + + throw new Error(`Found incorrect path in '${name}' field.`); + } + try { require.resolve(path.join(root, value)); } catch (e: unknown) { @@ -249,13 +275,13 @@ export default async function compile({ const generatedEntryPath = await getGeneratedEntryPath(); report.warn( - `No ${kleur.blue( - fields.map((field) => field.name).join(' or ') - )} field found in ${kleur.blue('package.json')}. Consider ${ + `No ${fields + .map((field) => kleur.blue(field.name)) + .join(' or ')} field found in ${kleur.blue('package.json')}. Consider ${ generatedEntryPath - ? `pointing it to ${kleur.blue(generatedEntryPath)}` - : 'adding it' - } so that consumers of your package can use it.` + ? `pointing to ${kleur.blue(generatedEntryPath)}` + : `adding ${fields.length > 1 ? 'them' : 'it'}` + } so that consumers of your package can import your package.` ); } }