diff --git a/docs/README.md b/docs/README.md index 247877ee..0d489270 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Delightful library bundler. - 🚀 Fast, zero-config by default. - 📦 Using Rollup under the hood. - 🚗 Automatically transforms JS files using Buble, Babel or TypeScript. +- 🧩 Bundles TypeScript declarations into a single `.d.ts` file. - 💅 Built-in support for CSS, Sass, Stylus, Less and CSS modules. - 🎶 Ridiculously easy to use Rollup plugins if you want. - 🚨 Friendly error logging experience. @@ -35,3 +36,9 @@ And you want minified bundles? ```bash bili --format esm-min --format cjs-min ``` + +Bundle TypeScript declarations too: + +```bash +bili src/index.ts --dts +``` diff --git a/docs/recipes/javascript.md b/docs/recipes/javascript.md index 466640c7..1b271cb2 100644 --- a/docs/recipes/javascript.md +++ b/docs/recipes/javascript.md @@ -22,7 +22,7 @@ module.exports = { presets: ['bili/babel'], plugins: [ // Add your babel plugins... - ] + ], } ``` @@ -53,6 +53,23 @@ We automatically use [rollup-plugin-typescript2](https://github.com/ezolenko/rol yarn add typescript rollup-plugin-typescript2 --dev ``` +Bili can also bundle emitted declarations into one `.d.ts` entry file: + +```bash +bili src/index.ts --dts +``` + +Or choose the declaration bundle file name: + +```js +// bili.config.js +module.exports = { + output: { + dts: 'index.d.ts', + }, +} +``` + ## Use Babel with TypeScript By default Babel is also used for `.ts` files, it will process the file after TypeScript. It's recommended to set `compilerOptions.target` to `es2017` or above in `tsconfig.json` and let Babel transform the code to ES5 instead. If you want to disable Babel, set `plugins: { babel: false }` in your Bili config file. diff --git a/package.json b/package.json index 9ca6d5ed..fc6d69d4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "chalk": "^4.1.0", "ora": "^4.0.4", "rollup": "^2.16.1", + "rollup-plugin-dts": "1.3.0", "rollup-plugin-hashbang": "^2.2.2", "rollup-plugin-postcss": "^3.1.2", "rollup-plugin-terser": "^6.1.0", diff --git a/src/cli.ts b/src/cli.ts index 6a13a725..8fa7e851 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,6 +47,10 @@ cli '--no-map', 'Disable source maps, enabled by default for minified bundles' ) + .option( + '--dts [fileName]', + 'Bundle TypeScript declarations into a single .d.ts file' + ) .option('--map-exclude-sources', 'Exclude source code in source maps') .option('--no-async-pro, --no-async-to-promises', 'Leave async/await as is') .option('--concurrent', 'Build concurrently') @@ -70,6 +74,7 @@ cli minify: options.minify, extractCSS: options.extractCss, sourceMap: options.map, + dts: options.dts, sourceMapExcludeSources: options.mapExcludeSources, target: options.target, }, diff --git a/src/index.ts b/src/index.ts index 2121a089..7b3ddc7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import './polyfills' import path from 'path' +import fs from 'fs' import colors from 'chalk' import prettyBytes from 'pretty-bytes' import formatTime from 'pretty-ms' @@ -7,6 +8,7 @@ import textTable from 'text-table' import resolveFrom from 'resolve-from' import boxen from 'boxen' import stringWidth from 'string-width' +import dts from 'rollup-plugin-dts' import { rollup, watch, @@ -61,6 +63,7 @@ interface RollupConfigInput { type PluginFactory = (opts: any) => RollupPlugin type GetPlugin = (name: string) => Promise +type SourceMeta = RollupConfigInput['source'] export class Bundler { rootDir: string @@ -198,6 +201,17 @@ export class Bundler { } } + const typescriptCompilerOptions: { [key: string]: any } = { + module: 'esnext', + } + const tsconfig = path.join(this.rootDir, 'tsconfig.json') + if (config.output.dts) { + Object.assign(typescriptCompilerOptions, { + declaration: true, + declarationMap: false, + }) + } + const pluginsOptions: { [key: string]: any } = { progress: config.plugins.progress !== false && @@ -248,11 +262,11 @@ export class Bundler { (source.hasTs || config.plugins.typescript2) && merge( { + cwd: this.rootDir, + ...(fs.existsSync(tsconfig) && { tsconfig }), objectHashIgnoreUnknownHack: getObjectHashIgnoreUnknownHack(), tsconfigOverride: { - compilerOptions: { - module: 'esnext', - }, + compilerOptions: typescriptCompilerOptions, }, }, config.plugins.typescript2 @@ -463,8 +477,8 @@ export class Bundler { // Since we only output to `.js` now // Probably remove it in the future .replace(/\[ext\]/, '.js') - - if (rollupFormat === 'esm') { + + if (rollupFormat === 'esm') { fileName = fileName.replace(/\[format\]/, 'esm') } @@ -567,6 +581,14 @@ export class Bundler { ...getMeta(files), } }) + const declarationSources = sources.filter( + (source) => source.hasTs && this.config.output.dts + ) + const existingDeclarationFiles = new Set( + declarationSources.length > 0 + ? findDeclarationFiles(path.resolve(this.config.output.dir || 'dist')) + : [] + ) let { format, target } = this.config.output if (Array.isArray(format)) { @@ -649,6 +671,14 @@ export class Bundler { context ) } + if (options.write && declarationSources.length > 0) { + await waterfall( + declarationSources.map((source) => () => { + return this.buildDtsBundle(source, existingDeclarationFiles) + }), + context + ) + } } catch (err) { spinner.stop() throw err @@ -678,6 +708,141 @@ export class Bundler { } } + async buildDtsBundle( + source: SourceMeta, + existingDeclarationFiles = new Set() + ) { + const outputDir = path.resolve(this.config.output.dir || 'dist') + this.emitDtsFiles(source, outputDir) + const sourceFile = getSingleDeclarationSource(source) + const inputFile = findDeclarationFile(outputDir, sourceFile) + if (!inputFile) { + throw new Error(`Declaration file for "${sourceFile}" was not emitted`) + } + + const outputFile = path.resolve( + outputDir, + getDtsFileName(this.config.output.dts, sourceFile) + ) + const tempFile = `${outputFile}.tmp` + const declarationFiles = findDeclarationFiles(outputDir).filter( + (file) => + !existingDeclarationFiles.has(file) && + file !== outputFile && + file !== tempFile + ) + const bundle = await rollup({ + input: inputFile, + plugins: [dts()], + }) + await bundle.write({ + file: tempFile, + format: 'es', + }) + fs.renameSync(tempFile, outputFile) + for (const file of declarationFiles) { + fs.unlinkSync(file) + } + + const relative = path.relative(process.cwd(), outputFile) + const assets: Assets = new Map() + assets.set(relative, { + absolute: outputFile, + get source() { + return fs.readFileSync(outputFile, 'utf8') + }, + }) + this.bundles.add(assets) + await printAssets(assets, 'Bundled declaration') + } + + emitDtsFiles(source: SourceMeta, outputDir: string) { + const ts = this.localRequire('typescript') + const tsconfig = path.join(this.rootDir, 'tsconfig.json') + let compilerOptions: { [key: string]: any } = {} + if (fs.existsSync(tsconfig)) { + const tsconfigJson = ts.readConfigFile(tsconfig, ts.sys.readFile) + if (tsconfigJson.error) { + throw new Error( + formatTsDiagnostics(ts, [tsconfigJson.error], this.rootDir) + ) + } + const parsedTsconfig = ts.parseJsonConfigFileContent( + tsconfigJson.config, + ts.sys, + this.rootDir + ) + if (parsedTsconfig.errors.length > 0) { + throw new Error( + formatTsDiagnostics(ts, parsedTsconfig.errors, this.rootDir) + ) + } + compilerOptions = parsedTsconfig.options + } + + compilerOptions = { + ...compilerOptions, + declaration: true, + declarationDir: outputDir, + declarationMap: false, + module: + compilerOptions.module === undefined + ? ts.ModuleKind.ESNext + : compilerOptions.module, + moduleResolution: + compilerOptions.moduleResolution === undefined + ? ts.ModuleResolutionKind.NodeJs + : compilerOptions.moduleResolution, + noEmit: false, + noEmitOnError: false, + outDir: outputDir, + skipLibCheck: + compilerOptions.skipLibCheck === undefined + ? true + : compilerOptions.skipLibCheck, + target: + compilerOptions.target === undefined + ? ts.ScriptTarget.ES2017 + : compilerOptions.target, + types: compilerOptions.types === undefined ? [] : compilerOptions.types, + } + + const program = ts.createProgram( + [ + this.resolveRootDir( + stripRelativePrefix(getSingleDeclarationSource(source)) + ), + ], + compilerOptions + ) + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic: any) => { + return diagnostic.category === ts.DiagnosticCategory.Error + }) + if (diagnostics.length > 0) { + throw new Error(formatTsDiagnostics(ts, diagnostics, this.rootDir)) + } + + const result = program.emit( + undefined, + (fileName: string, content: string) => { + if (!fileName.endsWith('.d.ts')) { + return + } + ensureDir(path.dirname(fileName)) + fs.writeFileSync(fileName, content) + }, + undefined, + false + ) + if (result.emitSkipped) { + throw new Error( + formatTsDiagnostics(ts, result.diagnostics || [], this.rootDir) + ) + } + } + handleError(err: any) { if (err.stack) { console.error() @@ -740,4 +905,84 @@ function getDefaultFileName(format: RollupFormat) { return format === 'cjs' ? `[name][min][ext]` : `[name].[format][min][ext]` } +function getSingleDeclarationSource(source: SourceMeta) { + if (Array.isArray(source.input)) { + if (source.input.length !== 1) { + throw new Error('output.dts requires a single TypeScript entry') + } + return source.input[0] + } + + const values = Object.values(source.input) + if (values.length !== 1) { + throw new Error('output.dts requires a single TypeScript entry') + } + return values[0] +} + +function getDtsFileName( + dtsOption: boolean | string | undefined, + sourceFile: string +) { + if (typeof dtsOption === 'string') { + return dtsOption.endsWith('.d.ts') ? dtsOption : `${dtsOption}.d.ts` + } + return `${path.basename(sourceFile).replace(/\.[cm]?[jt]sx?$/, '')}.d.ts` +} + +function stripRelativePrefix(file: string) { + return file.replace(/^\.\//, '') +} + +function findDeclarationFile(outputDir: string, sourceFile: string) { + const fileName = getDtsFileName(true, sourceFile) + const direct = path.resolve(outputDir, fileName) + if (fs.existsSync(direct)) { + return direct + } + + return findDeclarationFiles(outputDir).find( + (file) => path.basename(file) === fileName + ) +} + +function findDeclarationFiles(dir: string): string[] { + if (!fs.existsSync(dir)) { + return [] + } + + return fs.readdirSync(dir).reduce((files: string[], name) => { + const file = path.join(dir, name) + if (fs.statSync(file).isDirectory()) { + return files.concat(findDeclarationFiles(file)) + } + if (file.endsWith('.d.ts')) { + files.push(file) + } + return files + }, []) +} + +function ensureDir(dir: string) { + if (fs.existsSync(dir)) { + return + } + const parent = path.dirname(dir) + if (parent !== dir) { + ensureDir(parent) + } + fs.mkdirSync(dir) +} + +function formatTsDiagnostics(ts: any, diagnostics: any[], rootDir: string) { + if (diagnostics.length === 0) { + return 'TypeScript declaration emit failed' + } + return ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (fileName: string) => fileName, + getCurrentDirectory: () => rootDir, + getNewLine: () => '\n', + }) +} + export { Config, NormalizedConfig, Options, ConfigOutput } diff --git a/src/types.ts b/src/types.ts index dcca8edd..0c25a5f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import { ModuleFormat as RollupFormat, InputOptions, OutputOptions, - Plugin as RollupPlugin + Plugin as RollupPlugin, } from 'rollup' import { Banner } from './utils/get-banner' @@ -157,6 +157,14 @@ export interface ConfigOutput { * @default `true` for minified bundle, `false` otherwise */ sourceMap?: boolean + /** + * Bundle TypeScript declaration output into a single `.d.ts` file. + * + * Set to `true` to use the input basename, or pass a file name. + * + * @cli `--dts [fileName]` + */ + dts?: boolean | string /** * Exclude source code in source maps */ diff --git a/test/__snapshots__/index.test.ts.snap b/test/__snapshots__/index.test.ts.snap index 727dc002..c33ed2f6 100644 --- a/test/__snapshots__/index.test.ts.snap +++ b/test/__snapshots__/index.test.ts.snap @@ -196,6 +196,16 @@ module.exports = index; " `; +exports[`dts bundle 1`] = ` +"interface Message { + value: string; +} +declare function createMessage(value: string): Message; + +export { Message, createMessage }; +" +`; + exports[`exclude file: exclude file dist/index.js 1`] = ` "'use strict'; diff --git a/test/fixtures/declaration-bundle/index.ts b/test/fixtures/declaration-bundle/index.ts new file mode 100644 index 00000000..029ffe3a --- /dev/null +++ b/test/fixtures/declaration-bundle/index.ts @@ -0,0 +1 @@ +export { createMessage, Message } from './message' diff --git a/test/fixtures/declaration-bundle/message.ts b/test/fixtures/declaration-bundle/message.ts new file mode 100644 index 00000000..da0c227d --- /dev/null +++ b/test/fixtures/declaration-bundle/message.ts @@ -0,0 +1,7 @@ +export interface Message { + value: string +} + +export function createMessage(value: string): Message { + return { value } +} diff --git a/test/fixtures/declaration-bundle/tsconfig.json b/test/fixtures/declaration-bundle/tsconfig.json new file mode 100644 index 00000000..87d56766 --- /dev/null +++ b/test/fixtures/declaration-bundle/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "strict": true + }, + "include": ["*.ts"] +} diff --git a/test/index.test.ts b/test/index.test.ts index 1e281a15..13c66cfa 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,5 @@ import path from 'path' +import fs from 'fs' import { Bundler, Config, Options } from '../src' process.env.BABEL_ENV = 'anything-not-test' @@ -7,20 +8,35 @@ function fixture(...args: string[]) { return path.join(__dirname, 'fixtures', ...args) } -function generate(config: Config, options: Options) { +function removeDir(dir: string) { + if (!fs.existsSync(dir)) { + return + } + for (const name of fs.readdirSync(dir)) { + const file = path.join(dir, name) + if (fs.statSync(file).isDirectory()) { + removeDir(file) + } else { + fs.unlinkSync(file) + } + } + fs.rmdirSync(dir) +} + +function generate(config: Config, options: Options, runOptions: any = {}) { const bundler = new Bundler(config, { logLevel: 'quiet', configFile: false, - ...options + ...options, }) - return bundler.run() + return bundler.run(runOptions) } function snapshot( { title, input, - cwd + cwd, }: { title: string; input: string | string[]; cwd?: string }, config?: Config ) { @@ -28,10 +44,10 @@ function snapshot( const { bundles } = await generate( { input, - ...config + ...config, }, { - rootDir: cwd + rootDir: cwd, } ) for (const bundle of bundles) { @@ -46,17 +62,17 @@ function snapshot( snapshot({ title: 'defaults', input: 'index.js', - cwd: fixture('defaults') + cwd: fixture('defaults'), }) snapshot( { title: 'banner:true default', input: 'index.js', - cwd: fixture('banner/default') + cwd: fixture('banner/default'), }, { - banner: true + banner: true, } ) @@ -64,15 +80,15 @@ snapshot( { title: 'banner:object', input: 'default.js', - cwd: fixture() + cwd: fixture(), }, { banner: { author: 'author', license: 'GPL', name: 'name', - version: '1.2.3' - } + version: '1.2.3', + }, } ) @@ -80,10 +96,10 @@ snapshot( { title: 'banner:string', input: 'default.js', - cwd: fixture() + cwd: fixture(), }, { - banner: 'woot' + banner: 'woot', } ) @@ -91,10 +107,10 @@ snapshot( { title: 'exclude file', input: 'index.js', - cwd: fixture('exclude-file') + cwd: fixture('exclude-file'), }, { - externals: ['./foo.js'] + externals: ['./foo.js'], } ) @@ -102,11 +118,11 @@ snapshot( { title: 'extendOptions', input: ['foo.js', 'bar.js'], - cwd: fixture('extend-options') + cwd: fixture('extend-options'), }, { output: { - format: ['umd', 'umd-min', 'cjs'] + format: ['umd', 'umd-min', 'cjs'], }, extendConfig(config, { format }) { if (format === 'umd') { @@ -116,7 +132,7 @@ snapshot( config.output.moduleName = 'min' } return config - } + }, } ) @@ -124,54 +140,54 @@ snapshot( { title: 'bundle-node-modules', input: 'index.js', - cwd: fixture('bundle-node-modules') + cwd: fixture('bundle-node-modules'), }, { - bundleNodeModules: true + bundleNodeModules: true, } ) snapshot({ title: 'async', input: 'index.js', - cwd: fixture('async') + cwd: fixture('async'), }) snapshot({ title: 'babel:with-config', input: 'index.js', - cwd: fixture('babel/with-config') + cwd: fixture('babel/with-config'), }) snapshot( { title: 'babel:disable-config', input: 'index.js', - cwd: fixture('babel/with-config') + cwd: fixture('babel/with-config'), }, { babel: { - babelrc: false - } + babelrc: false, + }, } ) snapshot({ title: 'babel:object-rest-spread', input: 'index.js', - cwd: fixture('babel/object-rest-spread') + cwd: fixture('babel/object-rest-spread'), }) snapshot( { title: 'uglify', input: 'index.js', - cwd: fixture('uglify') + cwd: fixture('uglify'), }, { output: { - format: 'cjs-min' - } + format: 'cjs-min', + }, } ) @@ -179,37 +195,65 @@ snapshot( { title: 'inline-certain-modules', input: 'index.js', - cwd: fixture('inline-certain-modules') + cwd: fixture('inline-certain-modules'), }, { - bundleNodeModules: ['fake-module'] + bundleNodeModules: ['fake-module'], } ) snapshot({ title: 'vue plugin', input: 'component.vue', - cwd: fixture('vue') + cwd: fixture('vue'), }) snapshot({ title: 'Typescript', input: 'index.ts', - cwd: fixture('typescript') + cwd: fixture('typescript'), }) +test('dts bundle', async () => { + const cwd = fixture('declaration-bundle') + const dist = path.join(cwd, 'dist') + removeDir(dist) + await generate( + { + input: 'index.ts', + output: { + dir: dist, + dts: true, + }, + resolvePlugins: { + typescript2: require('rollup-plugin-typescript2'), + }, + }, + { + rootDir: cwd, + }, + { + write: true, + } + ) + expect(fs.existsSync(path.join(dist, 'message.d.ts'))).toBe(false) + expect( + fs.readFileSync(path.join(dist, 'index.d.ts'), 'utf8') + ).toMatchSnapshot() +}, 20000) + snapshot( { title: 'custom rollup plugin', input: 'index.js', - cwd: fixture('custom-rollup-plugin') + cwd: fixture('custom-rollup-plugin'), }, { plugins: { strip: { - functions: ['console.log'] - } - } + functions: ['console.log'], + }, + }, } ) @@ -217,14 +261,14 @@ snapshot( { title: 'custom scoped rollup plugin', input: 'index.js', - cwd: fixture('custom-scoped-rollup-plugin') + cwd: fixture('custom-scoped-rollup-plugin'), }, { plugins: { '@rollup/plugin-strip': { - functions: ['console.log'] - } - } + functions: ['console.log'], + }, + }, } ) @@ -232,14 +276,14 @@ snapshot( { title: '`@rollup/plugin-replace` can accepts custom options', input: 'index.js', - cwd: fixture('custom-scoped-rollup-plugin/replace') + cwd: fixture('custom-scoped-rollup-plugin/replace'), }, { plugins: { replace: { - 'process.env.NODE_ENV': JSON.stringify('production') - } - } + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + }, } ) @@ -247,12 +291,12 @@ snapshot( { title: 'target:browser', input: 'index.js', - cwd: fixture('target/browser') + cwd: fixture('target/browser'), }, { output: { - target: 'browser' - } + target: 'browser', + }, } ) @@ -260,13 +304,13 @@ snapshot( { title: 'umd and iife should drop process.env.NODE_ENV', input: 'index.js', - cwd: fixture('format') + cwd: fixture('format'), }, { output: { format: ['umd', 'umd-min', 'iife', 'iife-min'], - moduleName: 'dropNodeEnv' - } + moduleName: 'dropNodeEnv', + }, } ) @@ -274,13 +318,13 @@ snapshot( { title: 'umd and iife should preserve existing env.NODE_ENV', input: 'index.js', - cwd: fixture('format') + cwd: fixture('format'), }, { output: { format: ['umd', 'umd-min', 'iife', 'iife-min'], - moduleName: 'dropNodeEnv' + moduleName: 'dropNodeEnv', }, - env: { NODE_ENV: 'production' } + env: { NODE_ENV: 'production' }, } ) diff --git a/yarn.lock b/yarn.lock index 96fb332d..5c70e670 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9076,6 +9076,13 @@ rimraf@^3.0.0: dependencies: glob "^7.1.3" +rollup-plugin-dts@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-dts/-/rollup-plugin-dts-1.3.0.tgz#34de28ea8c9464392f2b0d4cb8cd0fe7c51d482e" + integrity sha512-G08HZvwliQdRbAOwNb1VnyKuRSp1EXpKPW5FrvRcHbxsmPP2Co443zZ0p8tSCTjuC5xNYyZ9VMzjcwtqrPn6Ew== + optionalDependencies: + "@babel/code-frame" "^7.8.3" + rollup-plugin-hashbang@^2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/rollup-plugin-hashbang/-/rollup-plugin-hashbang-2.2.2.tgz#971fc49b452e63f9dfdc75f79ae7256b3485e750"