diff --git a/README.md b/README.md index a65c5c9..b37301e 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,39 @@ export default defineConfig({ ## API -| Option | Description | -| --------- | -------------------------------------------------------- | -| `plugins` | Register `@rc-component/father-plugin` in father config. | +### Default imports in native ESM + +`cjsDefaultInterop` is **off by default**. When omitted or `false`, the plugin does not register the interop transformer or load its inspection dependencies; existing compiler output and import semantics are preserved. + +Opt in for a package that needs transpiled CommonJS defaults to work in native Node ESM. With Father 4.6.37 or newer: + +```ts | pure +import type {} from '@rc-component/father-plugin'; +import { defineConfig } from 'father'; + +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, + esm: { platform: 'node', autoExtension: true }, +}); +``` + +The type-only import enables the plugin's configuration types for `defineConfig`; it emits no runtime import. The switch is a top-level plugin option, separate from `esm` and `cjs`. Changing it also changes Father's per-file build cache key. + +When enabled, the plugin normalizes default imports from statically identifiable transpiled CommonJS dependencies in **Node ESM output only**. It resolves each package's Node **import** entry, then checks for `__esModule` and `default` exports without executing the dependency. Package names are not hardcoded: scoped packages, package subpaths, and statically identifiable CommonJS re-export entries are supported. + +Father keeps its default esbuild compiler for Node. The same output normalization also works with explicitly selected Babel or SWC, after their TypeScript/JSX transforms. Source maps are composed back to the original source. One small helper is generated per affected output file, so component source keeps ordinary default imports, including `import { default as Name }`. + +Entries identified as native ESM at build time and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies, unrecognized export structures, and output syntax unsupported by the inspection parser are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. + +**Enabling this option changes default-import semantics.** For a recognized CommonJS dependency, `import pkg from 'legacy'` receives its inner `default` value instead of the CommonJS exports object. Code that already calls `pkg.default()` or reads other properties of that object must be reviewed before enabling it. If a downstream resolver selects a native ESM entry after the build identified the dependency as CommonJS, the generated local variable captures the initial value; subsequent updates to that default export are not reflected. The runtime check does not preserve ESM live bindings in this case. Validate the package's supported consumers before opting in. + +This is a compatibility bridge until dependencies expose native ESM entries. Generated code still checks the loaded value at runtime. The parsing and resolution dependencies run only during the library build; no helper package is imported by the generated output. + +| Option | Default | Description | +| --- | --- | --- | +| `plugins` | — | Register `@rc-component/father-plugin` in father config. | +| `cjsDefaultInterop` | `false` | Opt in to CommonJS default-import normalization for Node ESM output. | ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 40c6f46..849c753 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,9 +40,39 @@ export default defineConfig({ ## API -| 名称 | 说明 | -| --------- | ---------------------------------------------------- | -| `plugins` | 在 father 配置中注册 `@rc-component/father-plugin`。 | +### 原生 ESM 的默认导入 + +`cjsDefaultInterop` **默认关闭**。不配置或设为 `false` 时,插件不注册 interop 编译处理,也不加载相关检查依赖,保留原有编译产物和导入语义。 + +需要让转译后的 CommonJS 默认导入在原生 Node ESM 中工作时,由组件库显式开启。使用 Father 4.6.37 或更高版本: + +```ts | pure +import type {} from '@rc-component/father-plugin'; +import { defineConfig } from 'father'; + +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, + esm: { platform: 'node', autoExtension: true }, +}); +``` + +类型导入为 `defineConfig` 加载插件的配置类型,不产生运行时导入。开关位于配置顶层,与 `esm`、`cjs` 同级。切换开关也会改变 Father 的文件构建缓存键。 + +开启后,仅对 **Node ESM 产物** 中能静态识别的转译后 CommonJS 依赖处理默认导入。插件按照 Node 的 **import** 条件解析依赖入口,检查 `__esModule` 和 `default` 导出,全程不执行依赖代码。不维护包名白名单,支持带 scope 的包、包子路径和可静态识别的 CommonJS 转导出入口。 + +Father 继续使用 Node 平台默认的 esbuild;显式选择 Babel 或 SWC 时也会在 TypeScript/JSX 编译完成后执行相同的处理,并将 source map 合并回原始源码。每个涉及的产物文件只生成一个小型兼容函数,组件源码保持普通默认导入,包括 `import { default as Name }`。 + +构建时识别为原生 ESM 的入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖、无法静态识别的导出结构以及检查用解析器不支持的产物语法也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 + +**开启此选项会改变默认导入语义。** 对于识别到的 CommonJS 依赖,`import pkg from 'legacy'` 拿到的是内部的 `default` 值,原本的 CommonJS 导出对象会被解包。因此,已有的 `pkg.default()` 调用或对该对象其他属性的访问需要先检查。如果构建时识别为 CommonJS,下游实际却选择了原生 ESM 入口,生成的局部变量会保存初始值,无法反映默认导出的后续更新;运行时检查不能保留这种情况下的 ESM 实时绑定。组件库应验证其支持的消费方式后再开启。 + +这是一项过渡措施,待依赖提供原生 ESM 入口后可移除。产物仍会在运行时检查导出值。解析相关依赖只在组件库构建时运行,产物不会额外导入 helper 包。 + +| 名称 | 默认值 | 说明 | +| --- | --- | --- | +| `plugins` | — | 在 father 配置中注册 `@rc-component/father-plugin`。 | +| `cjsDefaultInterop` | `false` | 显式开启 Node ESM 产物的 CommonJS 默认导入兼容处理。 | ## 本地开发 diff --git a/package.json b/package.json index 49ab66b..c6b7266 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "repository": "https://github.com/react-component/father-plugin.git", "license": "MIT", "main": "dist/index.js", + "types": "types.d.ts", "files": [ - "dist" + "dist", + "types.d.ts" ], "scripts": { "build": "father build", @@ -37,14 +39,22 @@ ] }, "dependencies": { - "fs-extra": "^11.3.0" + "@ampproject/remapping": "^2.3.0", + "acorn": "^8.18.0", + "cjs-module-lexer": "^2.2.1", + "enhanced-resolve": "^5.24.5", + "fs-extra": "^11.3.0", + "magic-string": "^0.30.21" }, "devDependencies": { + "@babel/core": "^7.29.7", "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", + "@jridgewell/trace-mapping": "^0.3.31", "@rc-component/np": "^1.0.4", + "@swc/core": "^1.16.2", "@types/fs-extra": "^11.0.4", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/defaultInterop.ts b/src/defaultInterop.ts new file mode 100644 index 0000000..5d52a95 --- /dev/null +++ b/src/defaultInterop.ts @@ -0,0 +1,160 @@ +import remapping from '@ampproject/remapping'; +import { parse as parseModule } from 'acorn'; +import { parse as parseCommonJS } from 'cjs-module-lexer'; +import { create } from 'enhanced-resolve'; +import fs from 'fs'; +import MagicString from 'magic-string'; +import { builtinModules, createRequire } from 'module'; +import path from 'path'; + +// Match Node's import branch, rather than accidentally inspecting a dual package's require entry. +const resolveImport = create.sync({ + conditionNames: ['node', 'import', 'default'], + mainFields: ['main'], + extensions: ['.js', '.json', '.node'], +}); + +function commonJSExports( + filename: string, + seen = new Set(), +): Set { + if (seen.has(filename) || !/\.c?js$/.test(filename)) return new Set(); + seen.add(filename); + + try { + const { exports, reexports } = parseCommonJS( + fs.readFileSync(filename, 'utf8'), + ); + const names = new Set(exports); + for (const request of reexports) { + try { + const dependency = createRequire(filename).resolve(request); + commonJSExports(dependency, seen).forEach((name) => names.add(name)); + } catch { + // Optional or unresolved re-exports cannot be classified statically. + } + } + return names; + } catch { + // Native ESM and unrecognized syntax must keep their original import semantics. + return new Set(); + } +} + +function needsInterop(request: string, importer: string): boolean { + if ( + /^(?:[./#]|[a-z][\w+.-]*:)/i.test(request) || + builtinModules.includes(request) + ) + return false; + + try { + const entry = resolveImport(path.dirname(importer), request); + if (!entry) return false; + const names = commonJSExports(entry); + return names.has('__esModule') && names.has('default'); + } catch { + return false; + } +} + +/** Normalize statically identifiable transpiled CommonJS defaults after JS compilation. */ +export default function defaultInterop( + code: string, + importer: string, + sourceMap?: string | null, +): [string, (string | null)?] { + const names = new Set(); + let program: ReturnType; + try { + program = parseModule(code, { + ecmaVersion: 'latest', + sourceType: 'module', + allowHashBang: true, + onToken(token) { + if ( + token.type.label === 'name' && + 'value' in token && + typeof token.value === 'string' + ) { + names.add(token.value); + } + }, + }); + } catch { + // Do not reject compiler output whose syntax this inspection parser cannot handle. + return [code, sourceMap]; + } + const uid = (name: string) => { + let candidate = `_${name}`; + while (names.has(candidate)) candidate += '_'; + names.add(candidate); + return candidate; + }; + const helper = uid('rcDefaultInterop'); + const output = new MagicString(code); + const declarations: string[] = []; + + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue; + const defaults = statement.specifiers.filter( + (specifier) => + specifier.type === 'ImportDefaultSpecifier' || + (specifier.type === 'ImportSpecifier' && + (specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value) === 'default'), + ); + if ( + !defaults.length || + !needsInterop(String(statement.source.value), importer) + ) + continue; + + for (const specifier of defaults) { + const imported = uid(`${specifier.local.name}Module`); + output.overwrite(specifier.local.start, specifier.local.end, imported); + declarations.push( + `var ${specifier.local.name} = ${helper}(${imported});`, + ); + } + } + + if (!declarations.length) return [code, sourceMap]; + + let insertion = code.startsWith('#!') ? code.indexOf('\n') + 1 : 0; + for (const statement of program.body) { + if (statement.type !== 'ExpressionStatement' || !statement.directive) break; + insertion = statement.end; + } + // TODO: Remove the bridge when the dependencies expose native ESM entries. + // Imports are hoisted; initialize aliases before any original executable statement. + output.appendLeft( + insertion, + ` +function ${helper}(value) { + return value && (typeof value === 'object' || typeof value === 'function') && + value.__esModule && 'default' in value ? value.default : value; +} +${declarations.join('\n')} +`, + ); + + const map = sourceMap + ? remapping( + [ + { + version: 3, + ...output.generateDecodedMap({ + source: importer, + includeContent: true, + hires: true, + }), + }, + sourceMap, + ], + () => null, + ).toString() + : sourceMap; + return [output.toString(), map]; +} diff --git a/src/index.ts b/src/index.ts index b1d78a8..f372846 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import type { IApi } from 'father'; import fs from 'fs-extra'; +import { createRequire } from 'module'; import path from 'path'; const cwd = process.cwd(); @@ -41,8 +42,33 @@ function checkNpmPackageDependency(packageJson: any, packageName: string) { } export default (api: IApi) => { + // Keep this separate from the shared plugin: false must only disable interop. + api.registerPlugins([ + { + id: 'virtual: rc-cjs-default-interop', + key: 'cjsDefaultInterop', + config: { + default: false, + schema: (joi: any) => joi.boolean().strict(), + }, + }, + ]); + // Compile break if export type without consistent api.onStart(async () => { + if ( + api.config.cjsDefaultInterop === true && + (api.name === 'build' || api.name === 'dev') + ) { + // Father 4 collects addJSTransformer before loading project plugins. + // Register after initialization, against the project's actual Father instance. + const projectRequire = createRequire(path.join(api.cwd, 'package.json')); + const { addTransformer } = projectRequire('father/dist/builder/bundless'); + for (const id of ['babel', 'esbuild', 'swc']) { + addTransformer({ id, transformer: require.resolve('./transformer') }); + } + } + if (api.name !== 'build') { return; } @@ -60,8 +86,7 @@ export default (api: IApi) => { process.exit(1); } - const inputFolder = - api?.config?.esm?.input || api?.config?.esm?.input || 'src/'; + const inputFolder = api.config.esm?.input || 'src/'; const isEslintInstalled = checkNpmPackageDependency(packageJson, 'eslint'); if (isEslintInstalled) { diff --git a/src/transformer.ts b/src/transformer.ts new file mode 100644 index 0000000..085e389 --- /dev/null +++ b/src/transformer.ts @@ -0,0 +1,23 @@ +import type { IFatherConfig, IJSTransformer } from 'father'; +import { createRequire } from 'module'; +import path from 'path'; +import defaultInterop from './defaultInterop'; + +type Transformer = NonNullable; + +// Delegate to Father's compiler so its JSX, aliases, targets, and source maps stay in effect. +const transformer: Transformer = async function (content) { + const { config, paths } = this; + const loadCompiler = createRequire(path.join(paths.cwd, 'package.json')); + const compile = loadCompiler( + `father/dist/builder/bundless/loaders/javascript/${config.transformer}`, + ).default; + const result = await compile.call(this, content); + return (config as IFatherConfig).cjsDefaultInterop === true && + config.format === 'esm' && + config.platform === 'node' + ? defaultInterop(result[0], paths.fileAbsPath, result[1]) + : result; +}; + +export default transformer; diff --git a/test/defaultInterop.test.js b/test/defaultInterop.test.js new file mode 100644 index 0000000..6fdfbc2 --- /dev/null +++ b/test/defaultInterop.test.js @@ -0,0 +1,423 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, test } = require('node:test'); +const { pathToFileURL } = require('node:url'); +const { TraceMap, originalPositionFor } = require('@jridgewell/trace-mapping'); +const normalize = require('../dist/defaultInterop').default; +const transformer = require('../dist/transformer').default; + +const fixtures = []; +afterEach(() => { + for (const directory of fixtures.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function writeFile(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + typeof content === 'string' ? content : JSON.stringify(content), + ); + return file; +} + +function loadOutput(directory, code) { + const entry = writeFile(path.join(directory, 'compiled.mjs'), code); + return import(pathToFileURL(entry).href); +} + +function fixture() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-interop-')); + fixtures.push(directory); + const add = (name, files, config = {}) => { + const root = path.join(directory, 'node_modules', name); + writeFile(path.join(root, 'package.json'), { + name, + main: 'index.js', + ...config, + }); + for (const [file, code] of Object.entries(files)) { + writeFile(path.join(root, file), code); + } + }; + const cjs = `Object.defineProperty(exports, '__esModule', { value: true }); + exports.marker = 'cjs'; exports.default = function component() { return exports.marker; };`; + add('any-legacy-package', { 'index.js': cjs }); + add( + '@example/components', + { + 'index.js': `module.exports = require('./component.js');`, + 'component.js': cjs, + }, + { exports: { '.': './index.js', './feature': './component.js' } }, + ); + add('plain-cjs', { + 'index.js': `module.exports = function plain() { return 'plain'; };`, + }); + fs.symlinkSync( + path.dirname(require.resolve('father/package.json')), + path.join(directory, 'node_modules/father'), + 'junction', + ); + writeFile(path.join(directory, 'package.json'), { + name: 'interop-fixture', + version: '1.0.0', + type: 'commonjs', + }); + add( + 'dual-package', + { + 'index.cjs': cjs, + 'index.js': `export const marker = 'esm'; + export let current = { __esModule: true, default: 'intentional ESM value' }; + export { current as default }; + export function update() { current = 'updated'; }`, + }, + { + type: 'module', + exports: { import: './index.js', require: './index.cjs' }, + }, + ); + return { directory, add, cjs }; +} + +async function run(directory, source) { + const [code] = normalize(source, path.join(directory, 'src.ts')); + return { module: await loadOutput(directory, code), code }; +} + +test('handles arbitrary package names, scoped subpaths, and CommonJS re-export entries', async () => { + const { directory } = fixture(); + const { module, code } = await run( + directory, + ` + import Component, { marker } from 'any-legacy-package'; + import { default as Wrapped } from '@example/components'; + import Deep from '@example/components/feature'; + export const result = [Component(), Wrapped(), Deep(), marker]; + export { Component as default }; + `, + ); + assert.deepEqual(module.result, ['cjs', 'cjs', 'cjs', 'cjs']); + assert.equal(module.default(), 'cjs'); + assert.equal((code.match(/function _rcDefaultInterop/g) || []).length, 1); +}); + +test('uses the import condition and preserves native ESM default values and live bindings', async () => { + const { directory } = fixture(); + const source = `import Value, { update } from 'dual-package'; + import Plain from 'plain-cjs'; + export const before = Value; + update(); + export const after = Value; + export const plain = Plain();`; + const { module, code } = await run(directory, source); + assert.equal(code, source); + assert.deepEqual(module.before, { + __esModule: true, + default: 'intentional ESM value', + }); + assert.equal(module.after, 'updated'); + assert.equal(module.plain, 'plain'); +}); + +test('keeps import hoisting, shadowed bindings, directives, and generated-name collisions', async () => { + const { directory } = fixture(); + const { module, code } = await run( + directory, + `'use client'; + export const result = Component(); + import Component from 'any-legacy-package'; + const _rcDefaultInterop = 'user helper'; + const _ComponentModule = 'user binding'; + export function shadow(Component) { return Component; } + export const names = [_rcDefaultInterop, _ComponentModule]; + `, + ); + assert.equal(module.result, 'cjs'); + assert.equal(module.shadow('local'), 'local'); + assert.deepEqual(module.names, ['user helper', 'user binding']); + assert.ok(code.startsWith("'use client';")); +}); + +test('leaves named, namespace, dynamic, relative, builtin, and unresolved imports unchanged', () => { + const { directory } = fixture(); + const source = ` + import { marker } from 'any-legacy-package'; + import * as ns from '@example/components'; + import 'any-legacy-package'; + import Relative from './local.js'; + import FS from 'fs'; + import HTTP from 'node:http'; + import Optional from 'not-installed'; + export const lazy = () => import('any-legacy-package'); + export { marker, ns, Relative, FS, HTTP, Optional }; + `; + assert.equal(normalize(source, path.join(directory, 'entry.js'))[0], source); +}); + +test('does not execute dependencies while inspecting their exports', () => { + const { directory, add, cjs } = fixture(); + add('must-not-execute', { + 'index.js': `throw new Error('executed at build time');\n${cjs}`, + }); + const [code] = normalize( + `import Value from 'must-not-execute'; export default Value;`, + path.join(directory, 'entry.js'), + ); + assert.match(code, /rcDefaultInterop/); +}); + +test('preserves compiler output with syntax unsupported by the inspection parser', () => { + const source = `import data from './data.json' assert { type: 'json' }; export default data;`; + const sourceMap = '{"version":3,"sources":[],"names":[],"mappings":""}'; + assert.deepEqual(normalize(source, '/project/entry.js', sourceMap), [ + source, + sourceMap, + ]); +}); + +test('checks runtime values when a downstream resolver selects another entry', async () => { + for (const value of [null, false, 0, 'native value']) { + const { directory, add, cjs } = fixture(); + add('switch-entry', { 'index.js': cjs }); + const [code] = normalize( + `import Value from 'switch-entry'; export default Value;`, + path.join(directory, 'entry.js'), + ); + assert.match(code, /rcDefaultInterop/); + add( + 'switch-entry', + { 'index.js': `export default ${JSON.stringify(value)};` }, + { type: 'module' }, + ); + assert.equal((await loadOutput(directory, code)).default, value); + } +}); + +test('handles cyclic CommonJS re-exports and ignores unrecognized export structures', () => { + const { directory, add } = fixture(); + add('cyclic', { + 'index.js': `module.exports = require('./other.js');`, + 'other.js': `module.exports = require('./index.js');`, + }); + const source = `import Value from 'cyclic'; export default Value;`; + assert.equal(normalize(source, path.join(directory, 'entry.js'))[0], source); +}); + +async function compile(directory, source, options = {}) { + const file = writeFile(path.join(directory, 'entry.ts'), source); + writeFile(path.join(directory, 'tsconfig.json'), { + compilerOptions: { target: 'ES2020' }, + }); + const context = { + config: { + transformer: 'esbuild', + format: 'esm', + platform: 'node', + cjsDefaultInterop: true, + sourcemap: true, + ...options, + }, + pkg: {}, + paths: { + cwd: directory, + fileAbsPath: file, + itemDistAbsPath: path.join(directory, 'dist/entry.mjs'), + }, + }; + return { result: await transformer.call(context, source), context }; +} + +for (const compiler of ['esbuild', 'babel', 'swc']) { + test(`keeps ${compiler} compilation and maps back to TypeScript source`, async () => { + const { directory } = fixture(); + const source = `import Component from 'any-legacy-package';\nexport const result: string = Component();`; + const { + result: [code, map], + } = await compile(directory, source, { transformer: compiler }); + assert.match(code, /rcDefaultInterop/); + assert.doesNotMatch(code, /: string/); + assert.ok(JSON.parse(map).sourcesContent.includes(source)); + const declaration = /(?:var|const) result\b/.exec(code); + assert.ok(declaration); + const prefix = code.slice(0, declaration.index); + const position = originalPositionFor(new TraceMap(map), { + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n') - 1, + }); + assert.equal(position.line, 2); + assert.ok(position.source.endsWith('entry.ts')); + assert.equal((await loadOutput(directory, code)).result, 'cjs'); + }); +} + +test('type-only imports are removed before interop analysis', async () => { + const { directory } = fixture(); + const { + result: [code], + } = await compile( + directory, + ` + import type Component from 'any-legacy-package'; + import Other from '@example/components'; + export type Value = [typeof Component, typeof Other]; + `, + ); + assert.doesNotMatch(code, /rcDefaultInterop|any-legacy-package|@example/); +}); + +test('returns the original compiler output when disabled, or for CJS and browser builds', async () => { + const { directory } = fixture(); + const source = `import Component from 'any-legacy-package'; export default Component;`; + const original = + require('father/dist/builder/bundless/loaders/javascript/esbuild').default; + for (const options of [ + { cjsDefaultInterop: undefined }, + { cjsDefaultInterop: false }, + { format: 'cjs' }, + { platform: 'browser' }, + ]) { + const { result, context } = await compile(directory, source, options); + assert.deepEqual(result, await original.call(context, source)); + } +}); + +test('a real Father build opts in with default esbuild and invalidates the cache when toggled', async () => { + const { directory } = fixture(); + writeFile( + path.join(directory, 'src/index.ts'), + `import Component from 'any-legacy-package'; export default Component;`, + ); + const entry = path.join(directory, 'es/index.mjs'); + let original; + let enabled; + for (const option of [undefined, true, false, true, undefined]) { + writeFile( + path.join(directory, '.fatherrc.ts'), + `export default ${JSON.stringify({ + plugins: [require.resolve('../dist')], + cjsDefaultInterop: option, + esm: { platform: 'node', autoExtension: true }, + })};`, + ); + const log = execFileSync( + process.execPath, + [require.resolve('father/bin/father.js'), 'build'], + { + cwd: directory, + env: { + ...process.env, + FATHER_CACHE: 'true', + FATHER_CACHE_DIR: path.join(directory, '.cache'), + }, + stdio: 'pipe', + encoding: 'utf8', + }, + ); + assert.ok(fs.existsSync(entry), log); + // The shared plugin's output defaults still apply even when interop is false. + assert.ok(fs.existsSync(path.join(directory, 'lib/index.js')), log); + const code = fs.readFileSync(entry, 'utf8'); + if (option === true) { + assert.match(code, /rcDefaultInterop/); + enabled ??= code; + assert.equal(code, enabled); + } else { + assert.doesNotMatch(code, /rcDefaultInterop/); + original ??= code; + assert.equal(code, original); + } + const consume = `import Component from './es/index.mjs'; + console.log(${option === true ? 'Component()' : 'Component.default()'});`; + assert.equal( + execFileSync(process.execPath, ['--input-type=module', '-e', consume], { + cwd: directory, + encoding: 'utf8', + }).trim(), + 'cjs', + ); + } + assert.ok( + fs.readdirSync(path.join(directory, '.cache/bundless-loader')).length, + ); +}); + +test('opting out preserves explicit .default access and downstream ESM live bindings', async () => { + for (const cjsDefaultInterop of [undefined, false]) { + const { directory, add, cjs } = fixture(); + add('switch-entry', { 'index.js': cjs }); + const source = `import Legacy from 'any-legacy-package'; + import Value, { update } from 'switch-entry'; + export const explicit = Legacy.default(); + export const read = () => Value; + export { Value as current, update };`; + const { + result: [code], + } = await compile(directory, source, { cjsDefaultInterop }); + assert.doesNotMatch(code, /rcDefaultInterop/); + // Model a downstream resolver choosing ESM after the library was built against CJS. + add( + 'switch-entry', + { + 'index.js': `let value = 1; export { value as default }; + export function update() { value = 2; }`, + }, + { type: 'module' }, + ); + const consumer = await loadOutput(directory, code); + assert.equal(consumer.explicit, 'cjs'); + assert.equal(consumer.read(), 1); + assert.equal(consumer.current, 1); + consumer.update(); + assert.equal(consumer.read(), 2); + assert.equal(consumer.current, 2); + } +}); + +test('the published declaration supports the opt-in in Father defineConfig', () => { + const { directory, add } = fixture(); + add( + '@rc-component/father-plugin', + { + 'types.d.ts': fs.readFileSync( + path.join(__dirname, '../types.d.ts'), + 'utf8', + ), + }, + { types: 'types.d.ts' }, + ); + const config = writeFile( + path.join(directory, '.fatherrc.ts'), + `import type {} from '@rc-component/father-plugin'; + import { defineConfig } from 'father'; + export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, + esm: { platform: 'node', autoExtension: true }, + }); + defineConfig({ cjsDefaultInterop: false }); + defineConfig({ + // @ts-expect-error Only booleans are accepted. + cjsDefaultInterop: 'true', + });`, + ); + execFileSync( + process.execPath, + [ + require.resolve('typescript/bin/tsc'), + '--noEmit', + '--skipLibCheck', + '--module', + 'commonjs', + '--target', + 'es2020', + config, + ], + { cwd: directory, stdio: 'pipe' }, + ); +}); diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..79d128a --- /dev/null +++ b/types.d.ts @@ -0,0 +1,15 @@ +import type { IApi } from 'father'; + +declare module 'father/dist/types' { + interface IFatherConfig { + /** + * Normalize transpiled CommonJS default imports in Node ESM output. + * Changes default-import semantics; see the plugin README before enabling. + * @default false + */ + cjsDefaultInterop?: boolean; + } +} + +declare const plugin: (api: IApi) => void; +export default plugin;