UNPKG

react-native-builder-bob

Version:

CLI to build JavaScript files for React Native libraries

340 lines 16.2 kB
import { createRequire } from 'node:module'; import { platform } from 'node:os'; import path from 'node:path'; import { deleteAsync } from 'del'; import fs from 'fs-extra'; import { glob } from 'glob'; import JSON5 from 'json5'; import kleur from 'kleur'; import ts from 'typescript'; import which from 'which'; import { isCodegenSpec } from "../utils/isCodegenSpec.js"; import { resolveModuleSpecifier, SOURCE_EXTENSIONS, } from "../utils/resolveModuleSpecifier.js"; import { spawn } from "../utils/spawn.js"; import { updateSourceMap } from "../utils/updateSourceMap.js"; const DECLARATION_EXTENSIONS = [{ source: 'd.ts', output: 'js' }]; const EXPLICIT_SOURCE_EXTENSIONS = ['ts', 'tsx'].map((source) => ({ source, emitted: DECLARATION_EXTENSIONS, })); const DECLARATION_REWRITE_BATCH_SIZE = 32; const getModuleSpecifier = (node) => { if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { return node.moduleSpecifier; } if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) { return node.argument.literal; } return undefined; }; const rewriteDeclarationImports = async (output, source, root, codegenEnabled) => { const isCodegenImport = (filepath, specifier) => { if (!codegenEnabled || !specifier.startsWith('.')) { return false; } // The declaration output mirrors the source tree, so map the import back to the source file const target = path.resolve(path.dirname(filepath), specifier); const relative = path.relative(output, target); // The tree root depends on tsc's inferred `rootDir`, so check both the project and source roots return [root, source].some((base) => { const candidate = path.join(base, relative); return (isCodegenSpec(candidate) || SOURCE_EXTENSIONS.some((ext) => isCodegenSpec(`${candidate}.${ext}`))); }); }; const files = await glob('**/*.d.ts', { cwd: output, absolute: true, nodir: true, }); // Process files in chunks to avoid firing read/writes for each file at once for (let i = 0; i < files.length; i += DECLARATION_REWRITE_BATCH_SIZE) { const promises = files .slice(i, i + DECLARATION_REWRITE_BATCH_SIZE) .map(async (filepath) => { const code = await fs.readFile(filepath, 'utf-8'); const sourceFile = ts.createSourceFile(filepath, code, ts.ScriptTarget.Latest, true); // Collect text changes before writing the file. const replacements = []; const addReplacement = (node) => { // Rewrite the module path if it points to an emitted file. // Only replace the text inside quotes to preserve the rest of tsc's output. const value = resolveModuleSpecifier({ filepath, specifier: node.text, extensions: DECLARATION_EXTENSIONS, explicitExtensions: EXPLICIT_SOURCE_EXTENSIONS, }); if (value !== node.text) { replacements.push({ start: node.getStart(sourceFile) + 1, end: node.getEnd() - 1, value, }); } }; const visit = (node) => { // Find module paths in this node. // Cover static imports/exports and import() types. const specifier = getModuleSpecifier(node); if (specifier && !isCodegenImport(filepath, specifier.text)) { addReplacement(specifier); } ts.forEachChild(node, visit); }; visit(sourceFile); if (replacements.length) { // Keep the source map in sync with the changed declaration file. const sourceMapPath = `${filepath}.map`; if (await fs.pathExists(sourceMapPath)) { await updateSourceMap({ filepath: sourceMapPath, replacements, sourceFile, }); } // Write the declaration file with the new module paths. await fs.writeFile(filepath, replacements // Apply edits from the end so earlier offsets stay valid. .sort((a, b) => b.start - a.start) .reduce((result, replacement) => result.slice(0, replacement.start) + replacement.value + result.slice(replacement.end), code)); } }); await Promise.all(promises); } }; export default async function build({ source, root, output, report, options, variants, esm, }) { report.info(`Cleaning up previous build at ${kleur.blue(path.relative(root, output))}`); await deleteAsync([output]); report.info(`Generating type definitions with ${kleur.blue('tsc')}`); const project = options?.project ? options.project : 'tsconfig.json'; const tsconfig = path.join(root, project); try { if (await fs.pathExists(tsconfig)) { try { const config = JSON5.parse(await fs.readFile(tsconfig, 'utf-8')); if (config.compilerOptions) { const conflicts = []; if (config.compilerOptions.declarationDir) { conflicts.push('compilerOptions.declarationDir'); } if (config.compilerOptions.outDir && path.join(root, config.compilerOptions.outDir) !== output) { conflicts.push('compilerOptions.outDir'); } if (conflicts.length) { report.warn(`Found following options in the config file which can conflict with the CLI options. Please remove them from ${kleur.blue(project)}:${conflicts.reduce((acc, curr) => acc + `\n${kleur.gray('-')} ${kleur.yellow(curr)}`, '')}`); } } } catch (e) { report.warn(`Couldn't parse ${kleur.blue(project)}. There might be validation errors.`); } } else { throw new Error(`Couldn't find a ${kleur.blue(project)} in the project root.`); } const args = [ '--pretty', '--declaration', '--declarationMap', '--noEmit', 'false', '--emitDeclarationOnly', '--project', project, ]; let command; if (options?.tsc) { command = path.resolve(root, options.tsc); if (!(await fs.pathExists(command))) { throw new Error(`The ${kleur.blue('tsc')} binary doesn't seem to be installed at ${kleur.blue(command)}. Please specify the correct path in options or remove it to use the workspace's version.`); } } else { try { const manifest = createRequire(path.join(root, 'package.json')).resolve('typescript/package.json'); const { bin } = JSON.parse(await fs.readFile(manifest, 'utf-8')); // Run the binary with node command command = process.execPath; args.unshift(path.join(path.dirname(manifest), bin.tsc)); } catch { const binary = platform() === 'win32' ? 'tsc.cmd' : 'tsc'; command = await which(binary, { nothrow: true }); if (command == null) { throw new Error(`The ${kleur.blue('tsc')} binary doesn't seem to be installed in the workspace or present in $PATH. Make sure you have added ${kleur.blue('typescript')} to your ${kleur.blue('devDependencies')} or specify the ${kleur.blue('tsc')} option for typescript.`); } report.warn(`Failed to resolve ${kleur.blue('tsc')} in the workspace. Falling back to the binary found in ${kleur.blue('PATH')} at ${kleur.blue(command)}. Consider adding ${kleur.blue('typescript')} to your ${kleur.blue('devDependencies')} or specifying the ${kleur.blue('tsc')} option for the typescript target.`); } } const outputs = {}; 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.'); } args.push('--outDir', outDir); const tsbuildinfo = path.join(outDir, project.replace(/\.json$/, '.tsbuildinfo')); try { await deleteAsync([tsbuildinfo]); } catch (e) { // Ignore } await spawn(command, args, { cwd: root }); try { await deleteAsync([tsbuildinfo]); } catch (e) { // Ignore } const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf-8')); const codegenEnabled = 'codegenConfig' in pkg; 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', }); } if (outputs.module) { await rewriteDeclarationImports(outputs.module, source, root, codegenEnabled); } } report.success(`Wrote definition files to ${kleur.blue(path.relative(root, output))}`); const fields = [ { name: 'types', value: pkg.types, output: outputs.commonjs, error: false, message: undefined, }, ...(pkg.exports?.['.']?.types ? [ { name: "exports['.'].types", value: pkg.exports?.['.']?.types, output: outDir, error: Boolean(pkg.exports?.['.']?.import && pkg.exports?.['.']?.require), message: `using ${kleur.blue("exports['.'].import")} and ${kleur.blue("exports['.'].require")}. Specify ${kleur.blue("exports['.'].import.types")} and ${kleur.blue("exports['.'].require.types")} instead.`, }, ] : []), { name: "exports['.'].import.types", value: pkg.exports?.['.']?.import?.types, output: outputs.module, 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: false, message: undefined, }, ]; const getGeneratedTypesPath = async (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(field.output, path.dirname(pkg.source), indexDTsName), path.join(field.output, path.relative(source, path.join(root, path.dirname(pkg.source))), indexDTsName), ]; for (const potentialPath of potentialPaths) { if (await fs.pathExists(potentialPath)) { return path.relative(root, potentialPath); } } } return null; }; const invalidFieldNames = (await Promise.all(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 ${String(field.message)}.`); } return null; } 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)}`); return field.name; } if (field.value && !(await fs.pathExists(path.join(root, field.value)))) { const generatedTypesPath = await getGeneratedTypesPath(field); report.error(`The ${kleur.blue(field.name)} field in ${kleur.blue('package.json')} points to a non-existent file: ${kleur.blue(field.value)}.\nVerify the path points to the correct file under ${kleur.blue(path.relative(root, output))}${generatedTypesPath ? ` (found ${kleur.blue(generatedTypesPath)}).` : '.'}`); return field.name; } return null; }))).filter((name) => name != null); if (invalidFieldNames.length) { throw new Error(`Found errors for fields: ${invalidFieldNames.join(', ')}.`); } const validFields = fields.filter((field) => !field.error); if (validFields.every((field) => field.value == null)) { const suggestedTypesPaths = (await Promise.all(validFields.map(async (field) => getGeneratedTypesPath(field)))) .filter((path) => path != null) .filter((path, i, self) => self.indexOf(path) === i); report.warn(`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.`); } } catch (e) { if (e != null && typeof e === 'object') { if ('stdout' in e && e.stdout != null) { report.error(`Errors found when building definition files:\n${e.stdout.toString()}`); } else if ('message' in e && typeof e.message === 'string') { report.error(e.message); } } throw new Error('Failed to build definition files.', { cause: e }); } } //# sourceMappingURL=typescript.js.map