diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index bcea54a29..390b8af6c 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -1,58 +1,23 @@ -import { createFilter } from '@rollup/pluginutils'; +import * as path from 'path'; + import { Plugin } from 'rollup'; -import * as defaultTs from 'typescript'; import { RollupTypescriptOptions } from '../types'; -import { - adjustCompilerOptions, - getDefaultOptions, - parseCompilerOptions, - readTsConfig, - validateModuleType -} from './options'; import { diagnosticToWarning, emitDiagnostics } from './diagnostics'; -import { getTsLibCode, TSLIB_ID } from './tslib'; +import { getPluginOptions, parseTypescriptConfig } from './options'; +import { TSLIB_ID } from './tslib'; export default function typescript(options: RollupTypescriptOptions = {}): Plugin { - const opts = Object.assign({}, options); - - const filter = createFilter( - opts.include || ['*.ts+(|x)', '**/*.ts+(|x)'], - opts.exclude || ['*.d.ts', '**/*.d.ts'] - ); - delete opts.include; - delete opts.exclude; - - // Allow users to override the TypeScript version used for transpilation and tslib version used for helpers. - const ts: typeof import('typescript') = opts.typescript || defaultTs; - delete opts.typescript; - - const tslib = getTsLibCode(opts); - delete opts.tslib; - - // Load options from `tsconfig.json` unless explicitly asked not to. - const tsConfig = - opts.tsconfig === false ? { compilerOptions: {} } : readTsConfig(ts, opts.tsconfig); - delete opts.tsconfig; - - // Since the CompilerOptions aren't designed for the Rollup - // use case, we'll adjust them for use with Rollup. - tsConfig.compilerOptions = adjustCompilerOptions(tsConfig.compilerOptions); - - Object.assign(tsConfig.compilerOptions, getDefaultOptions(), adjustCompilerOptions(opts)); - - // Verify that we're targeting ES2015 modules. - validateModuleType(tsConfig.compilerOptions.module); - - const { options: compilerOptions, errors } = parseCompilerOptions(ts, tsConfig); + const { filter, tsconfig, compilerOptions, tslib, typescript: ts } = getPluginOptions(options); + const parsedConfig = parseTypescriptConfig(ts, tsconfig, compilerOptions); return { name: 'typescript', buildStart() { - if (errors.length > 0) { - errors.forEach((error) => this.warn(diagnosticToWarning(ts, error))); + if (parsedConfig.errors.length > 0) { + parsedConfig.errors.forEach((error) => this.warn(diagnosticToWarning(ts, error))); this.error(`@rollup/plugin-typescript: Couldn't process compiler options`); } @@ -64,9 +29,14 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi } if (!importer) return null; - const containingFile = importer.split('\\').join('/'); + const containingFile = importer.split(path.win32.sep).join(path.posix.sep); - const result = ts.nodeModuleNameResolver(importee, containingFile, compilerOptions, ts.sys); + const result = ts.nodeModuleNameResolver( + importee, + containingFile, + parsedConfig.options, + ts.sys + ); if (result.resolvedModule && result.resolvedModule.resolvedFileName) { if (result.resolvedModule.resolvedFileName.endsWith('.d.ts')) { @@ -92,7 +62,7 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi const transformed = ts.transpileModule(code, { fileName: id, reportDiagnostics: true, - compilerOptions + compilerOptions: parsedConfig.options }); emitDiagnostics(ts, this, transformed.diagnostics); diff --git a/packages/typescript/src/options.ts b/packages/typescript/src/options.ts index 144a1116e..f544b5839 100644 --- a/packages/typescript/src/options.ts +++ b/packages/typescript/src/options.ts @@ -1,81 +1,248 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; -export function getDefaultOptions() { +import { createFilter } from '@rollup/pluginutils'; +import * as defaultTs from 'typescript'; + +import { RollupTypescriptOptions } from '../types'; + +import { diagnosticToWarning } from './diagnostics'; +import { getTsLibCode } from './tslib'; + +/** Properties of `CompilerOptions` that are normally enums */ +interface EnumCompilerOptions { + module: string; + moduleResolution: string; + newLine: string; + jsx: string; + target: string; +} + +/** Typescript compiler options */ +type CompilerOptions = import('typescript').CompilerOptions; +/** JSON representation of Typescript compiler options */ +type JsonCompilerOptions = Omit & EnumCompilerOptions; +/** Compiler options set by the plugin user. */ +type PartialCustomOptions = Partial | Partial; + +const DEFAULT_COMPILER_OPTIONS: PartialCustomOptions = { + module: 'esnext', + sourceMap: true, + noEmitOnError: true +}; + +const FORCED_COMPILER_OPTIONS: Partial = { + // See: https://github.com/rollup/rollup-plugin-typescript/issues/45 + // See: https://github.com/rollup/rollup-plugin-typescript/issues/142 + declaration: false, + // Delete the `declarationMap` option, as it will cause an error, because we have + // deleted the `declaration` option. + declarationMap: false, + incremental: false, + // eslint-disable-next-line no-undefined + tsBuildInfoFile: undefined, + // Always use tslib + noEmitHelpers: true, + importHelpers: true, + // Typescript needs to emit the code for us to work with + noEmit: false, + emitDeclarationOnly: false, + // Preventing Typescript from resolving code may break compilation + noResolve: false +}; + +/** + * Separate the Rollup plugin options from the Typescript compiler options, + * and normalize the Rollup options. + * @returns Object with normalized options: + * - `filter`: Checks if a file should be included. + * - `tsconfig`: Path to a tsconfig, or directive to ignore tsconfig. + * - `compilerOptions`: Custom Typescript compiler options that override tsconfig. + * - `typescript`: Instance of Typescript library (possibly custom). + * - `tslib`: ESM code from the tslib helper library (possibly) + */ +export function getPluginOptions(options: RollupTypescriptOptions) { + const { include, exclude, tsconfig, typescript, tslib, ...compilerOptions } = options; + + const filter = createFilter( + include || ['*.ts+(|x)', '**/*.ts+(|x)'], + exclude || ['*.d.ts', '**/*.d.ts'] + ); + return { - noEmitHelpers: true, - module: 'ESNext', - sourceMap: true, - importHelpers: true + filter, + tsconfig, + compilerOptions: compilerOptions as PartialCustomOptions, + typescript: typescript || defaultTs, + tslib: getTsLibCode(tslib) }; } -export function readTsConfig(ts: typeof import('typescript'), tsconfigPath: string | undefined) { - if (tsconfigPath && !ts.sys.fileExists(tsconfigPath)) { - throw new Error(`Could not find specified tsconfig.json at ${tsconfigPath}`); - } - const existingTsConfig = tsconfigPath || ts.findConfigFile(process.cwd(), ts.sys.fileExists); - if (!existingTsConfig) { - return {}; - } - - const tsconfig = ts.readConfigFile(existingTsConfig, (path) => readFileSync(path, 'utf8')); +/** + * Finds the path to the tsconfig file relative to the current working directory. + * @param relativePath Relative tsconfig path given by the user. + * If `false` is passed, then a null path is returned. + * @returns The absolute path, or null if the file does not exist. + */ +function getTsConfigPath(ts: typeof import('typescript'), relativePath: string | false) { + if (relativePath === false) return null; - if (!tsconfig.config || !tsconfig.config.compilerOptions) return { compilerOptions: {} }; + // Resolve path to file. `tsConfigOption` defaults to 'tsconfig.json'. + const tsConfigPath = resolve(process.cwd(), relativePath || 'tsconfig.json'); - const extendedTsConfig: string = tsconfig.config.extends; - if (tsconfigPath && extendedTsConfig) { - tsconfig.config.extends = resolve(process.cwd(), existingTsConfig, '..', extendedTsConfig); + if (!ts.sys.fileExists(tsConfigPath)) { + if (relativePath) { + // If an explicit path was provided but no file was found, throw + throw new Error(`Could not find specified tsconfig.json at ${tsConfigPath}`); + } else { + return null; + } } - return tsconfig.config; + return tsConfigPath; } -export function adjustCompilerOptions(options: any) { - const opts = Object.assign({}, options); - // Set `sourceMap` to `inlineSourceMap` if it's a boolean - // under the assumption that both are never specified simultaneously. - if (typeof opts.inlineSourceMap === 'boolean') { - opts.sourceMap = opts.inlineSourceMap; - delete opts.inlineSourceMap; +/** + * Tries to read the tsconfig file at `tsConfigPath`. + * @param tsConfigPath Absolute path to tsconfig JSON file. + * @param explicitPath If true, the path was set by the plugin user. + * If false, the path was computed automatically. + */ +function readTsConfigFile(ts: typeof import('typescript'), tsConfigPath: string) { + const { config, error } = ts.readConfigFile(tsConfigPath, (path) => readFileSync(path, 'utf8')); + if (error) { + throw Object.assign(Error(), diagnosticToWarning(ts, error)); } - // Delete some options to prevent compilation error. - // See: https://github.com/rollup/rollup-plugin-typescript/issues/45 - // See: https://github.com/rollup/rollup-plugin-typescript/issues/142 - delete opts.declaration; - // Delete the `declarationMap` option, as it will cause an error, because we have - // deleted the `declaration` option. - delete opts.declarationMap; - delete opts.incremental; - delete opts.tsBuildInfoFile; - return opts; + const extendedTsConfig: string = config?.extends; + if (extendedTsConfig) { + // Get absolute path of `extends`, starting at basedir of the tsconfig file. + config.extends = resolve(process.cwd(), tsConfigPath, '..', extendedTsConfig); + } + + return config || {}; } -export function parseCompilerOptions(ts: typeof import('typescript'), tsConfig: any) { - const parsed = ts.convertCompilerOptionsFromJson(tsConfig.compilerOptions, process.cwd()); +/** + * Returns true if any of the `compilerOptions` contain an enum value (i.e.: ts.ScriptKind) rather than a string. + * This indicates that the internal CompilerOptions type is used rather than the JsonCompilerOptions. + */ +function containsEnumOptions( + compilerOptions: PartialCustomOptions +): compilerOptions is Partial { + const enums: Array = [ + 'module', + 'target', + 'jsx', + 'moduleResolution', + 'newLine' + ]; + return enums.some((prop) => prop in compilerOptions && typeof compilerOptions[prop] === 'number'); +} - // let typescript load inheritance chain if there are base configs - const extendedConfig = tsConfig.extends - ? ts.parseJsonConfigFileContent(tsConfig, ts.sys, process.cwd(), parsed.options) - : null; +/** + * Mutates the compiler options to normalize some values for Rollup. + * @param compilerOptions Compiler options to _mutate_. + */ +function normalizeCompilerOptions( + ts: typeof import('typescript'), + compilerOptions: CompilerOptions +) { + /* eslint-disable no-param-reassign */ - return { - options: extendedConfig?.options || parsed.options, - errors: parsed.errors.concat(extendedConfig?.errors || []) - }; + if (compilerOptions.inlineSourceMap) { + // Force separate source map files for Rollup to work with. + compilerOptions.sourceMap = true; + compilerOptions.inlineSourceMap = false; + } else if (typeof compilerOptions.sourceMap !== 'boolean') { + // Default to using source maps. + // If the plugin user sets sourceMap to false we keep that option. + compilerOptions.sourceMap = true; + } + + switch (compilerOptions.module) { + case ts.ModuleKind.ES2015: + case ts.ModuleKind.ESNext: + case ts.ModuleKind.CommonJS: + // OK module type + return; + case ts.ModuleKind.None: + case ts.ModuleKind.AMD: + case ts.ModuleKind.UMD: + case ts.ModuleKind.System: { + // Invalid module type + const moduleType = ts.ModuleKind[compilerOptions.module]; + throw new Error( + `@rollup/plugin-typescript: The module kind should be 'ES2015' or 'ESNext, found: '${moduleType}'` + ); + } + default: + // Unknown or unspecified module type, force ESNext + compilerOptions.module = ts.ModuleKind.ESNext; + } } /** - * Verify that we're targeting ES2015 modules. - * @param moduleType `tsConfig.compilerOptions.module` + * Parse the Typescript config to use with the plugin. + * @param ts Typescript library instance. + * @param tsconfig Path to the tsconfig file, or `false` to ignore the file. + * @param compilerOptions Options passed to the plugin directly for Typescript. + * + * @returns Parsed tsconfig.json file with some important properties: + * - `options`: Parsed compiler options. + * - `fileNames` Type definition files that should be included in the build. + * - `errors`: Any errors from parsing the config file. */ -export function validateModuleType(moduleType: string) { - const esModuleTypes = new Set(['ES2015', 'ES6', 'ESNEXT', 'COMMONJS']); +export function parseTypescriptConfig( + ts: typeof import('typescript'), + tsconfig: RollupTypescriptOptions['tsconfig'], + compilerOptions: PartialCustomOptions +): import('typescript').ParsedCommandLine { + const cwd = process.cwd(); + let parsedConfig: import('typescript').ParsedCommandLine; + + // Resolve path to file. If file is not found, pass undefined path to `parseJsonConfigFileContent`. + // eslint-disable-next-line no-undefined + const tsConfigPath = getTsConfigPath(ts, tsconfig) || undefined; + const tsConfigFile = tsConfigPath ? readTsConfigFile(ts, tsConfigPath) : {}; - if (!esModuleTypes.has(moduleType.toUpperCase())) { - throw new Error( - `@rollup/plugin-typescript: The module kind should be 'ES2015' or 'ESNext, found: '${moduleType}'` + // If compilerOptions has enums, it represents an CompilerOptions object instead of parsed JSON. + // This determines where the data is passed to the parser. + if (containsEnumOptions(compilerOptions)) { + parsedConfig = ts.parseJsonConfigFileContent( + { + ...tsConfigFile, + compilerOptions: { + ...DEFAULT_COMPILER_OPTIONS, + ...tsConfigFile.compilerOptions + } + }, + ts.sys, + cwd, + { ...compilerOptions, ...FORCED_COMPILER_OPTIONS }, + tsConfigPath + ); + } else { + parsedConfig = ts.parseJsonConfigFileContent( + { + ...tsConfigFile, + compilerOptions: { + ...DEFAULT_COMPILER_OPTIONS, + ...tsConfigFile.compilerOptions, + ...compilerOptions + } + }, + ts.sys, + cwd, + FORCED_COMPILER_OPTIONS, + tsConfigPath ); } + + // We only want to automatically add ambient declaration files. + // Normal script files are handled by Rollup. + parsedConfig.fileNames = parsedConfig.fileNames.filter((file) => file.endsWith('.d.ts')); + normalizeCompilerOptions(ts, parsedConfig.options); + + return parsedConfig; } diff --git a/packages/typescript/src/tslib.ts b/packages/typescript/src/tslib.ts index 48b75b78e..8f9d5eb59 100644 --- a/packages/typescript/src/tslib.ts +++ b/packages/typescript/src/tslib.ts @@ -2,8 +2,6 @@ import { readFile } from 'fs'; import resolveId, { AsyncOpts } from 'resolve'; -import { RollupTypescriptOptions } from '../types'; - export const TSLIB_ID = '\0tslib'; const readFileAsync = (file: string) => @@ -18,10 +16,10 @@ const resolveIdAsync = (file: string, opts?: AsyncOpts) => /** * Returns code asynchronously for the tslib helper library. - * @param opts.tslib Overrides the injected helpers with a custom version. + * @param customCode Overrides the injected helpers with a custom version. */ -export async function getTsLibCode(opts: Pick) { - if (opts.tslib) return opts.tslib; +export async function getTsLibCode(customHelperCode: string | Promise) { + if (customHelperCode) return customHelperCode; const defaultPath = await resolveIdAsync('tslib/tslib.es6.js', { basedir: __dirname }); return readFileAsync(defaultPath); diff --git a/packages/typescript/test/test.js b/packages/typescript/test/test.js index 99cbe4b6d..3373f8e8a 100644 --- a/packages/typescript/test/test.js +++ b/packages/typescript/test/test.js @@ -39,19 +39,42 @@ test('ignores the declaration option', async (t) => { }); test('throws for unsupported module types', async (t) => { - let caughtError = null; - try { - await rollup({ + const caughtError = t.throws(() => + rollup({ input: 'fixtures/basic/main.ts', - plugins: [typescript({ module: 'ES5' })] - }); - } catch (error) { - caughtError = error; - } + plugins: [typescript({ module: 'amd' })] + }) + ); - t.truthy(caughtError, 'Throws an error.'); t.true( - caughtError.message.includes("The module kind should be 'ES2015' or 'ESNext, found: 'ES5'"), + caughtError.message.includes("The module kind should be 'ES2015' or 'ESNext, found: 'AMD'"), + `Unexpected error message: ${caughtError.message}` + ); +}); + +test('warns for invalid module types', async (t) => { + const warnings = []; + const caughtError = await t.throwsAsync(() => + rollup({ + input: 'fixtures/basic/main.ts', + plugins: [typescript({ module: 'ES5' })], + onwarn({ toString, ...warning }) { + // Can't match toString with deepEqual, so remove it here + warnings.push(warning); + } + }) + ); + + t.deepEqual(warnings, [ + { + code: 'PLUGIN_WARNING', + plugin: 'typescript', + pluginCode: 'TS6046', + message: `@rollup/plugin-typescript TS6046: Argument for '--module' option must be: 'none', 'commonjs', 'amd', 'system', 'umd', 'es6', 'es2015', 'esnext'.` + } + ]); + t.true( + caughtError.message.includes(`@rollup/plugin-typescript: Couldn't process compiler options`), `Unexpected error message: ${caughtError.message}` ); }); @@ -388,7 +411,7 @@ test('supports dynamic imports', async (t) => { t.true(code.includes("console.log('dynamic')")); }); -test('supports CommonJS imports when the output format is CommonJS', async (t) => { +test.serial('supports CommonJS imports when the output format is CommonJS', async (t) => { const bundle = await rollup({ input: 'fixtures/commonjs-imports/main.ts', plugins: [typescript({ module: 'CommonJS' }), commonjs({ extensions: ['.ts', '.js'] })] @@ -400,6 +423,16 @@ test('supports CommonJS imports when the output format is CommonJS', async (t) = function fakeTypescript(custom) { return Object.assign( { + ModuleKind: { + None: 0, + CommonJS: 1, + AMD: 2, + UMD: 3, + System: 4, + ES2015: 5, + ESNext: 99 + }, + transpileModule() { return { outputText: '', @@ -419,6 +452,14 @@ function fakeTypescript(custom) { options, errors: [] }; + }, + + parseJsonConfigFileContent(json, host, basePath, existingOptions) { + return { + options: existingOptions, + errors: [], + fileNames: [] + }; } }, custom diff --git a/packages/typescript/tsconfig.json b/packages/typescript/tsconfig.json index 842f6deb5..3a0b8cf71 100644 --- a/packages/typescript/tsconfig.json +++ b/packages/typescript/tsconfig.json @@ -10,7 +10,7 @@ "allowJs": true }, "files": [ - "index.d.ts", + "types", "typings-test.js" ] }