UNPKG

bob-the-bundler

Version:
366 lines (365 loc) • 15.6 kB
import assert from 'assert'; import { dirname, join, resolve } from 'path'; import { execa } from 'execa'; import fse from 'fs-extra'; import { globby } from 'globby'; import get from 'lodash.get'; import pLimit from 'p-limit'; import { createCommand } from '../command.js'; import { getBobConfig } from '../config.js'; import { getRootPackageJSON } from '../utils/get-root-package-json.js'; import { getWorkspacePackagePaths } from '../utils/get-workspace-package-paths.js'; import { getWorkspaces } from '../utils/get-workspaces.js'; import { rewriteExports } from '../utils/rewrite-exports.js'; import { presetFields, presetFieldsESM } from './bootstrap.js'; export const DIST_DIR = 'dist'; export const DEFAULT_TS_BUILD_CONFIG = 'tsconfig.build.json'; /** * A list of files that we don't need within the published package. * Also known as test files :) * This list is derived from scouting various of our repositories. */ const filesToExcludeFromDist = [ '**/test/**', '**/tests/**', '**/__tests__/**', '**/__testUtils__/**', '**/*.spec.*', '**/*.test.*', '**/dist', '**/temp', ]; const moduleMappings = { esm: 'es2022', cjs: 'commonjs', }; function typeScriptCompilerOptions(target) { return { module: moduleMappings[target], sourceMap: false, inlineSourceMap: false, }; } function compilerOptionsToArgs(options) { return Object.entries(options).flatMap(([key, value]) => [`--${key}`, `${value}`]); } function assertTypeScriptBuildResult(result) { if (result.exitCode !== 0) { console.log('TypeScript compiler exited with non-zero exit code.'); console.log(result.stdout); throw new Error('TypeScript compiler exited with non-zero exit code.'); } } async function buildTypeScript(buildPath, options) { let tsconfig = options.tsconfig; if (!tsconfig && (await fse.exists(join(options.cwd, DEFAULT_TS_BUILD_CONFIG)))) { tsconfig = join(options.cwd, DEFAULT_TS_BUILD_CONFIG); } assertTypeScriptBuildResult(await execa('npx', [ 'tsc', ...(tsconfig ? ['--project', tsconfig] : []), ...compilerOptionsToArgs(typeScriptCompilerOptions('esm')), ...(options.incremental ? ['--incremental'] : []), '--outDir', join(buildPath, 'esm'), ])); assertTypeScriptBuildResult(await execa('npx', [ 'tsc', ...(tsconfig ? ['--project', tsconfig] : []), ...compilerOptionsToArgs(typeScriptCompilerOptions('cjs')), ...(options.incremental ? ['--incremental'] : []), '--outDir', join(buildPath, 'cjs'), ])); } export const buildCommand = createCommand(api => { const { reporter } = api; return { command: 'build', describe: 'Build', builder(yargs) { return yargs.options({ tsconfig: { describe: `Which tsconfig file to use when building TypeScript. By default bob will use ${DEFAULT_TS_BUILD_CONFIG} if it exists, otherwise the TSC's default.`, type: 'string', }, incremental: { describe: 'Better performance by building only packages that had changes.', type: 'boolean', }, }); }, async handler({ tsconfig, incremental }) { const cwd = process.cwd(); const rootPackageJSON = await getRootPackageJSON(); const workspaces = await getWorkspaces(rootPackageJSON); const isSinglePackage = workspaces === null; if (isSinglePackage) { const buildPath = join(cwd, '.bob'); if (!incremental) { await fse.remove(buildPath); } await buildTypeScript(buildPath, { cwd, tsconfig, incremental }); const pkg = await fse.readJSON(resolve(cwd, 'package.json')); const fullName = pkg.name; const distPath = join(cwd, 'dist'); const getBuildPath = (target) => join(buildPath, target); await build({ cwd, pkg, fullName, reporter, getBuildPath, distPath, }); return; } const limit = pLimit(4); const workspacePackagePaths = await getWorkspacePackagePaths(workspaces); const packageInfoList = await Promise.all(workspacePackagePaths.map(packagePath => limit(async () => { const cwd = packagePath; const pkg = await fse.readJSON(resolve(cwd, 'package.json')); const fullName = pkg.name; return { packagePath, cwd, pkg, fullName }; }))); const bobBuildPath = join(cwd, '.bob'); if (!incremental) { await fse.remove(bobBuildPath); } await buildTypeScript(bobBuildPath, { cwd, tsconfig, incremental }); await Promise.all(packageInfoList.map(({ cwd, pkg, fullName }) => limit(async () => { const getBuildPath = (target) => join(cwd.replace('packages', join('.bob', target)), 'src'); const distPath = join(cwd, 'dist'); await build({ cwd, pkg, fullName, reporter, getBuildPath, distPath, }); }))); }, }; }); const limit = pLimit(20); async function build({ cwd, pkg, fullName, reporter, getBuildPath, distPath, }) { var _a, _b, _c; const config = getBobConfig(pkg); if (config === false || (config === null || config === void 0 ? void 0 : config.build) === false) { reporter.warn(`Skip build for '${fullName}'`); return; } validatePackageJson(pkg, { includesCommonJS: (_a = config === null || config === void 0 ? void 0 : config.commonjs) !== null && _a !== void 0 ? _a : true, }); const declarations = await globby('**/*.d.ts', { cwd: getBuildPath('esm'), absolute: false, ignore: filesToExcludeFromDist, }); await fse.ensureDir(join(distPath, 'typings')); await Promise.all(declarations.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'typings', filePath))))); const esmFiles = await globby('**/*.js', { cwd: getBuildPath('esm'), absolute: false, ignore: filesToExcludeFromDist, }); // all files that export nothing, should be completely empty // this way we wont have issues with linters: <link to issue> // and we will also make all type-only packages happy for (const file of esmFiles) { const src = await fse.readFile(join(getBuildPath('esm'), file)); if (src.toString().trim() === 'export {};') { await fse.writeFile(join(getBuildPath('esm'), file), ''); } } await fse.ensureDir(join(distPath, 'esm')); await Promise.all(esmFiles.map(filePath => limit(() => fse.copy(join(getBuildPath('esm'), filePath), join(distPath, 'esm', filePath))))); if ((config === null || config === void 0 ? void 0 : config.commonjs) === undefined) { // Transpile ESM to CJS and move CJS to dist/cjs only if there's something to transpile await fse.ensureDir(join(distPath, 'cjs')); const cjsFiles = await globby('**/*.js', { cwd: getBuildPath('cjs'), absolute: false, ignore: filesToExcludeFromDist, }); // all files that export nothing, should be completely empty // this way we wont have issues with linters: <link to issue> // and we will also make all type-only packages happy for (const file of cjsFiles) { const src = await fse.readFile(join(getBuildPath('cjs'), file)); if ( // TODO: will this always be the case with empty cjs files src.toString().trim() === '"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });') { await fse.writeFile(join(getBuildPath('cjs'), file), ''); } } await Promise.all(cjsFiles.map(filePath => limit(() => fse.copy(join(getBuildPath('cjs'), filePath), join(distPath, 'cjs', filePath))))); // Add package.json to dist/cjs to ensure files are interpreted as commonjs await fse.writeFile(join(distPath, 'cjs', 'package.json'), JSON.stringify({ type: 'commonjs' })); // We need to provide .cjs extension type definitions as well :) // https://github.com/ardatan/graphql-tools/discussions/4581#discussioncomment-3329673 const declarations = await globby('**/*.d.ts', { cwd: getBuildPath('cjs'), absolute: false, ignore: filesToExcludeFromDist, }); await Promise.all(declarations.map(filePath => limit(async () => { const contents = await fse.readFile(join(getBuildPath('cjs'), filePath), 'utf-8'); await fse.writeFile(join(distPath, 'typings', filePath.replace(/\.d\.ts/, '.d.cts')), contents.replace(/\.js";\n/g, `.cjs";\n`).replace(/\.js';\n/g, `.cjs';\n`)); }))); } // move the package.json to dist await fse.writeFile(join(distPath, 'package.json'), JSON.stringify(rewritePackageJson(pkg), null, 2)); // move README.md and LICENSE and other specified files await copyToDist(cwd, ['README.md', 'LICENSE', ...((_c = (_b = config === null || config === void 0 ? void 0 : config.build) === null || _b === void 0 ? void 0 : _b.copy) !== null && _c !== void 0 ? _c : [])], distPath); if (pkg.bin) { if (globalThis.process.platform === 'win32') { console.warn('Package includes bin files, but cannot set the executable bit on Windows.\n' + 'Please manually set the executable bit on the bin files before publishing.'); } else { await Promise.all(Object.values(pkg.bin).map(filePath => execa('chmod', ['+x', join(cwd, filePath)]))); } } reporter.success(`Built ${pkg.name}`); } function rewritePackageJson(pkg) { const newPkg = {}; const fields = [ 'name', 'version', 'description', 'sideEffects', 'peerDependenciesMeta', 'peerDependencies', 'dependencies', 'optionalDependencies', 'repository', 'homepage', 'keywords', 'author', 'license', 'engines', 'name', 'main', 'module', 'typings', 'typescript', 'type', ]; fields.forEach(field => { if (pkg[field] !== undefined) { newPkg[field] = pkg[field]; if (field === 'engines') { // remove all package managers from engines field const ignoredPackageManagers = ['npm', 'yarn', 'pnpm']; for (const packageManager of ignoredPackageManagers) { if (newPkg[field][packageManager]) { delete newPkg[field][packageManager]; } } } } }); const distDirStr = `${DIST_DIR}/`; newPkg.main = newPkg.main.replace(distDirStr, ''); newPkg.module = newPkg.module.replace(distDirStr, ''); newPkg.typings = newPkg.typings.replace(distDirStr, ''); newPkg.typescript = { definition: newPkg.typescript.definition.replace(distDirStr, ''), }; if (!pkg.exports) { newPkg.exports = presetFields.exports; } newPkg.exports = rewriteExports(pkg.exports, DIST_DIR); if (pkg.bin) { newPkg.bin = {}; for (const alias in pkg.bin) { newPkg.bin[alias] = pkg.bin[alias].replace(distDirStr, ''); } } return newPkg; } export function validatePackageJson(pkg, opts) { var _a; function expect(key, expected) { const received = get(pkg, key); assert.deepEqual(received, expected, `${pkg.name}: "${key}" equals "${JSON.stringify(received)}"` + `, should be "${JSON.stringify(expected)}".`); } // If the package has NO binary we need to check the exports map. // a package should either // 1. have a bin property // 2. have an exports property // 3. have an exports and bin property if (Object.keys((_a = pkg.bin) !== null && _a !== void 0 ? _a : {}).length > 0) { if (opts.includesCommonJS === true) { expect('main', presetFields.main); expect('module', presetFields.module); expect('typings', presetFields.typings); expect('typescript.definition', presetFields.typescript.definition); } else { expect('main', presetFieldsESM.main); expect('module', presetFieldsESM.module); expect('typings', presetFieldsESM.typings); expect('typescript.definition', presetFieldsESM.typescript.definition); } } else if (pkg.main !== undefined || pkg.module !== undefined || pkg.exports !== undefined || pkg.typings !== undefined || pkg.typescript !== undefined) { if (opts.includesCommonJS === true) { // if there is no bin property, we NEED to check the exports. expect('main', presetFields.main); expect('module', presetFields.module); expect('typings', presetFields.typings); expect('typescript.definition', presetFields.typescript.definition); // For now we enforce a top level exports property expect("exports['.'].require", presetFields.exports['.'].require); expect("exports['.'].import", presetFields.exports['.'].import); expect("exports['.'].default", presetFields.exports['.'].default); } else { expect('main', presetFieldsESM.main); expect('module', presetFieldsESM.module); expect('typings', presetFieldsESM.typings); expect('typescript.definition', presetFieldsESM.typescript.definition); // For now, we enforce a top level exports property expect("exports['.']", presetFieldsESM.exports['.']); } } } async function executeCopy(sourcePath, destPath) { await fse.mkdirp(dirname(destPath)); await fse.copyFile(sourcePath, destPath); } async function copyToDist(cwd, files, distDir) { const allFiles = await globby(files, { cwd }); return Promise.all(allFiles.map(async (file) => { if (await fse.pathExists(join(cwd, file))) { const sourcePath = join(cwd, file); if (file.includes('src/')) { // Figure relevant module types const allTypes = []; if (await fse.pathExists(join(distDir, 'esm'))) { allTypes.push('esm'); } if (await fse.pathExists(join(distDir, 'cjs'))) { allTypes.push('cjs'); } // FOr each type, copy files to the relevant directory await Promise.all(allTypes.map(type => executeCopy(sourcePath, join(distDir, file.replace('src/', `${type}/`))))); } else { const destPath = join(distDir, file); executeCopy(sourcePath, destPath); } } })); }