Skip to content

Commit

Permalink
feat: add esm build option for typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
satya164 committed Jul 30, 2024
1 parent cc30f7f commit f8a725f
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 65 deletions.
20 changes: 15 additions & 5 deletions docs/pages/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ yarn add --dev react-native-builder-bob
"targets": [
["commonjs", { "esm": true }],
["module", { "esm": true }],
"typescript",
["typescript", { "esm": true }]
]
}
```
Expand Down Expand Up @@ -76,12 +76,16 @@ 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",
"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": [
Expand Down Expand Up @@ -224,6 +228,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:
Expand Down
18 changes: 12 additions & 6 deletions docs/pages/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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"
}
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
172 changes: 130 additions & 42 deletions packages/react-native-builder-bob/src/targets/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
[
Expand All @@ -168,7 +186,7 @@ export default async function build({
'--project',
project,
'--outDir',
output,
outputs.commonjs,
],
{
stdio: 'inherit',
Expand All @@ -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))}`
);
Expand All @@ -187,16 +217,52 @@ export default async function build({
await fs.readFile(path.join(root, 'package.json'), 'utf-8')
);

const getGeneratedTypesPath = async () => {
const fields: Field[] = [
...(pkg.exports?.['.']?.types
? [
{
name: "exports['.'].types",
value: pkg.exports?.['.']?.types,
output: outputs.commonjs,
error: options?.esm === true,
},
]
: [
{
name: 'types',
value: pkg.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
),
];
Expand All @@ -211,37 +277,63 @@ 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.error(
`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 field.name;
}

return null;
}

const typesPath = path.join(root, value);
if (
field.name.startsWith('exports') &&
field.value &&
!/^\.\//.test(field.value)
) {
report.warn(
`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')}.`
);
}
const generatedTypesPath = await getGeneratedTypesPath(field);
const isValid =
field.value && generatedTypesPath
? path.join(root, field.value) ===
path.join(root, generatedTypesPath)
: false;

if (!isValid) {
const type =
field.value &&
(await fs.pathExists(path.join(root, field.value)))
? 'invalid'
: 'non-existent';

report.error(
`The ${kleur.blue(name)} field in ${kleur.blue(
'package.json'
)} points to a non-existent file: ${kleur.blue(
value
)}.\nVerify the path points to the correct file under ${kleur.blue(
`The ${kleur.blue(field.name)} field ${
field.value
? `in ${kleur.blue(
'package.json'
)} points to a ${type} file: ${kleur.blue(field.value)}`
: `is missing in ${kleur.blue('package.json')}`
}.\nVerify the path points to the correct file under ${kleur.blue(
path.relative(root, output)
)}${
generatedTypesPath
Expand All @@ -250,21 +342,17 @@ export default async function build({
}`
);

throw new Error(`Found incorrect path in '${name}' field.`);
return field.name;
}

return null;
})
);
} else {
const generatedTypesPath = await getGeneratedTypesPath();
)
).filter((name): name is string => name != null);

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.`
if (invalidFieldNames.length) {
throw new Error(
`Found errors for fields: ${invalidFieldNames.join(', ')}.`
);
}
} else {
Expand Down
42 changes: 34 additions & 8 deletions packages/react-native-builder-bob/src/utils/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit f8a725f

Please sign in to comment.