UNPKG

prepare-package

Version:

Prepare a Node.js package before being published

240 lines (204 loc) 7.35 kB
const path = require('path'); const fs = require('fs'); const logger = require('./logger'); const { default: chalk } = require('chalk'); /** * Build using esbuild based on preparePackage config * * @param {object} options * @param {string} options.cwd - Working directory * @param {object} options.packageJSON - Consumer's package.json * @param {object} options.config - preparePackage config * @returns {Promise<void>} */ async function build(options) { const esbuild = require('esbuild'); const { cwd, packageJSON, config } = options; const buildConfig = config.build || {}; const version = packageJSON.version; const inputPath = path.resolve(cwd, config.input || './src'); const outputPath = path.resolve(cwd, config.output || './dist'); // Resolve entry point const entry = path.resolve(cwd, buildConfig.entry || path.join(config.input || './src', 'index.js')); // Default formats const formats = buildConfig.formats || ['esm', 'cjs']; // Version replacement plugin const versionPlugin = { name: 'version-replace', setup(build) { build.onLoad({ filter: /\.js$/ }, async (args) => { let contents = fs.readFileSync(args.path, 'utf8'); contents = contents.replace(/\{version\}/g, version); return { contents, loader: 'js', watchFiles: [args.path] }; }); }, }; // Shared options const shared = { entryPoints: [entry], bundle: true, sourcemap: buildConfig.sourcemap || false, plugins: [versionPlugin], external: buildConfig.external || [], }; const target = buildConfig.target || 'es2020'; const platform = buildConfig.platform || 'neutral'; const builds = []; // ESM if (formats.includes('esm')) { builds.push({ ...shared, outfile: path.join(outputPath, 'index.mjs'), format: 'esm', platform, minify: false, target, }); } // CJS if (formats.includes('cjs')) { const cjsConfig = buildConfig.cjs || {}; builds.push({ ...shared, outfile: path.join(outputPath, 'index.js'), format: 'cjs', platform, minify: false, target, // Unwrap default export so require() returns the function/class directly footer: { js: cjsConfig.footer || 'module.exports=module.exports.default||module.exports;' }, }); } // IIFE (UMD/CDN) if (formats.includes('iife')) { const iife = buildConfig.iife || {}; const globalName = iife.globalName; if (!globalName) { throw new Error('preparePackage.build.iife.globalName is required when using "iife" format'); } const fileName = iife.fileName || `${packageJSON.name}.min.js`; builds.push({ ...shared, outfile: path.join(outputPath, fileName), format: 'iife', globalName, minify: true, target: iife.target || 'es2015', // Unwrap default export so window[globalName] is the class directly footer: { js: `${globalName}=${globalName}.default||${globalName}.${globalName}||${globalName};` }, }); } // Build all formats await Promise.all(builds.map(config => esbuild.build(config))); const formatNames = formats.map(f => f.toUpperCase()).join(', '); logger.log(chalk.green(`Built ${packageJSON.name} v${version} (${formatNames})`)); } /** * Create esbuild watch contexts for watch mode * * @param {object} options - Same as build() * @returns {Promise<object[]>} Array of esbuild contexts */ async function createWatchContexts(options) { const esbuild = require('esbuild'); const { cwd, packageJSON, config } = options; const buildConfig = config.build || {}; const version = packageJSON.version; const inputPath = path.resolve(cwd, config.input || './src'); const outputPath = path.resolve(cwd, config.output || './dist'); const entry = path.resolve(cwd, buildConfig.entry || path.join(config.input || './src', 'index.js')); const formats = buildConfig.formats || ['esm', 'cjs']; // In watch mode, replace {version} in output files AFTER build (onEnd) // instead of intercepting file loading (onLoad), so esbuild's native // file watcher can detect source changes. let isFirstBuild = true; let rebuildTimer = null; const watchVersionPlugin = { name: 'version-replace-watch', setup(build) { build.onEnd((result) => { // Replace {version} in the output file const outfile = build.initialOptions.outfile; if (outfile && fs.existsSync(outfile)) { const contents = fs.readFileSync(outfile, 'utf8'); if (contents.includes('{version}')) { fs.writeFileSync(outfile, contents.replace(/\{version\}/g, version)); } } // Skip first build (the "Watching..." message covers it) if (isFirstBuild) { return; } // Debounce rebuild log across all format contexts if (result.errors.length > 0) { logger.error(chalk.red(`Build failed with ${result.errors.length} error(s)`)); } else if (!rebuildTimer) { rebuildTimer = setTimeout(() => { rebuildTimer = null; logger.log(chalk.green(`Rebuilt ${packageJSON.name} v${version}`)); }, 50); } }); }, }; const shared = { entryPoints: [entry], bundle: true, sourcemap: buildConfig.sourcemap || false, plugins: [watchVersionPlugin], external: buildConfig.external || [], }; const target = buildConfig.target || 'es2020'; const platform = buildConfig.platform || 'neutral'; const builds = []; if (formats.includes('esm')) { builds.push({ ...shared, outfile: path.join(outputPath, 'index.mjs'), format: 'esm', platform, minify: false, target, }); } if (formats.includes('cjs')) { const cjsConfig = buildConfig.cjs || {}; builds.push({ ...shared, outfile: path.join(outputPath, 'index.js'), format: 'cjs', platform, minify: false, target, // Unwrap default export so require() returns the function/class directly footer: { js: cjsConfig.footer || 'module.exports=module.exports.default||module.exports;' }, }); } if (formats.includes('iife')) { const iife = buildConfig.iife || {}; const globalName = iife.globalName; if (!globalName) { throw new Error('preparePackage.build.iife.globalName is required when using "iife" format'); } const fileName = iife.fileName || `${packageJSON.name}.min.js`; builds.push({ ...shared, outfile: path.join(outputPath, fileName), format: 'iife', globalName, minify: true, target: iife.target || 'es2015', footer: { js: `${globalName}=${globalName}.default||${globalName}.${globalName}||${globalName};` }, }); } const contexts = await Promise.all( builds.map(config => esbuild.context(config)), ); await Promise.all(contexts.map(ctx => ctx.watch())); // Delay flipping the flag so initial onEnd callbacks complete first await new Promise(resolve => setTimeout(() => { isFirstBuild = false; resolve(); }, 100)); const formatNames = formats.map(f => f.toUpperCase()).join(', '); logger.log(chalk.green(`Watching ${packageJSON.name} v${version} (${formatNames})...`)); return contexts; } module.exports = { build, createWatchContexts };