diff --git a/README.md b/README.md index a65c5c9..b60c29c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ | Package | `@rc-component/father-plugin` | | Release | `@rc-component/np` / `rc-np` | +Packages whose `exports.import` targets the Father ESM output automatically receive Node-compatible JavaScript specifiers, matching declaration specifiers, and an ESM package marker. Packages without native ESM exports keep their existing output unchanged. + ## Install ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 40c6f46..1084e14 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,6 +22,8 @@ | 包名 | `@rc-component/father-plugin` | | 发布 | `@rc-component/np` / `rc-np` | +当包的 `exports.import` 指向 Father 的 ESM 输出时,插件会自动补全 Node 可解析的 JavaScript 路径、同步声明文件路径,并生成 ESM 类型标记。未声明原生 ESM 导出的包保持原有产物不变。 + ## 安装 ```bash diff --git a/src/babelPluginAddEsmExtensions.ts b/src/babelPluginAddEsmExtensions.ts new file mode 100644 index 0000000..73c7c0d --- /dev/null +++ b/src/babelPluginAddEsmExtensions.ts @@ -0,0 +1,33 @@ +import { resolveSourceEsmSpecifier } from './nativeEsm'; + +function replaceSource(path: any, state: any) { + const source = path.node.source; + const filename = state.filename || path.hub?.file?.opts?.filename; + + if (source?.value && filename) { + source.value = resolveSourceEsmSpecifier(filename, source.value); + } +} + +function replaceDynamicImport(path: any, state: any) { + if (path.node.callee?.type !== 'Import') { + return; + } + + const source = path.node.arguments?.[0]; + const filename = state.filename || path.hub?.file?.opts?.filename; + if (source?.type === 'StringLiteral' && filename) { + source.value = resolveSourceEsmSpecifier(filename, source.value); + } +} + +export default function addEsmExtensions() { + return { + visitor: { + CallExpression: replaceDynamicImport, + ExportAllDeclaration: replaceSource, + ExportNamedDeclaration: replaceSource, + ImportDeclaration: replaceSource, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index b1d78a8..10114e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import type { IApi } from 'father'; import fs from 'fs-extra'; import path from 'path'; +import { finalizeNativeEsmOutput, hasNativeEsmExport } from './nativeEsm'; + const cwd = process.cwd(); const restrictedPackageDirectoryImports = [ @@ -41,6 +43,10 @@ function checkNpmPackageDependency(packageJson: any, packageName: string) { } export default (api: IApi) => { + const packageJson = fs.readJsonSync(path.join(cwd, 'package.json')); + const esmOutput = api.userConfig.esm?.output || 'es'; + const nativeEsm = hasNativeEsmExport(packageJson.exports, esmOutput); + // Compile break if export type without consistent api.onStart(async () => { if (api.name !== 'build') { @@ -50,8 +56,6 @@ export default (api: IApi) => { console.log('Check Typescript exports and rc package directory imports...'); // Break if current project not install `@rc-component/np` - const packageJson = await fs.readJson(path.join(cwd, 'package.json')); - if ( checkNpmPackageDependency(packageJson, 'np') && !checkNpmPackageDependency(packageJson, '@rc-component/np') @@ -80,13 +84,30 @@ export default (api: IApi) => { } }); + api.onAllBuildComplete(() => { + if (api.name !== 'build' || !nativeEsm) { + return; + } + + const output = api.config.esm?.output || esmOutput; + const rewriteCount = finalizeNativeEsmOutput(path.resolve(cwd, output)); + console.log( + `Prepared native ESM output with ${rewriteCount} declaration specifier rewrites.`, + ); + }); + // modify default build config for all rc projects api.modifyDefaultConfig((memo) => { Object.assign(memo, { esm: { output: 'es', // transform all rc-xx/lib to rc-xx/es for esm build - extraBabelPlugins: [require.resolve('./babelPluginImportLib2Es')], + extraBabelPlugins: [ + require.resolve('./babelPluginImportLib2Es'), + ...(nativeEsm + ? [require.resolve('./babelPluginAddEsmExtensions')] + : []), + ], }, cjs: { // specific platform to browser, father 4 build cjs for node by default diff --git a/src/nativeEsm.ts b/src/nativeEsm.ts new file mode 100644 index 0000000..5a5cc24 --- /dev/null +++ b/src/nativeEsm.ts @@ -0,0 +1,258 @@ +import fs from 'fs-extra'; +import path from 'path'; + +const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs']; +const declarationExtensions = ['.d.ts', '.d.mts', '.d.cts']; +const runtimeExtensions = ['.js', '.mjs', '.cjs']; +const moduleSpecifierPattern = + /(\b(?:from|import|require)\s*(?:\(\s*)?)(['"])(\.\.?(?:\/[^'"]*)?)\2(\s*\)?)/g; + +function hasExtension(specifier: string) { + return Boolean(path.extname(specifier)); +} + +function normalizeOutputDirectory(output: string) { + return output + .replace(/^\.\//, '') + .replace(/[\\/]$/, '') + .replace(/\\/g, '/'); +} + +function targetUsesOutput(target: unknown, output: string): boolean { + if (Array.isArray(target)) { + return target.some((item) => targetUsesOutput(item, output)); + } + + if (typeof target !== 'string') { + return false; + } + + const normalizedOutput = normalizeOutputDirectory(output); + return ( + target === `./${normalizedOutput}` || + target.startsWith(`./${normalizedOutput}/`) + ); +} + +export function hasNativeEsmExport( + exportsField: unknown, + output: string, +): boolean { + if (!exportsField || typeof exportsField !== 'object') { + return false; + } + + return Object.entries(exportsField).some(([condition, target]) => { + if (condition === 'import') { + return targetUsesOutput(target, output); + } + + return hasNativeEsmExport(target, output); + }); +} + +function isSourceFile(absoluteSpecifier: string) { + return sourceExtensions.some((extension) => + fs.existsSync(`${absoluteSpecifier}${extension}`), + ); +} + +function isSourceDirectory(absoluteSpecifier: string) { + return sourceExtensions.some((extension) => + fs.existsSync(path.join(absoluteSpecifier, `index${extension}`)), + ); +} + +function getPackageName(specifier: string) { + const segments = specifier.split('/'); + return specifier.startsWith('@') + ? segments.slice(0, 2).join('/') + : segments[0]; +} + +function findPackage( + resolvedPath: string, + packageName: string, +): { directory: string; packageJson: any } | undefined { + let directory = path.dirname(resolvedPath); + + while (true) { + const packageJsonPath = path.join(directory, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = fs.readJsonSync(packageJsonPath); + if (packageJson.name === packageName) { + return { directory, packageJson }; + } + } + + const parentDirectory = path.dirname(directory); + if (parentDirectory === directory) { + return undefined; + } + directory = parentDirectory; + } +} + +function resolvePackageEsmSpecifier(filePath: string, specifier: string) { + const packageName = getPackageName(specifier); + if (!packageName || specifier === packageName) { + return specifier; + } + + let resolvedPath: string; + try { + resolvedPath = require.resolve(specifier, { + paths: [path.dirname(filePath)], + }); + } catch { + return specifier; + } + + const packageInfo = findPackage(resolvedPath, packageName); + if ( + !packageInfo || + Object.prototype.hasOwnProperty.call(packageInfo.packageJson, 'exports') + ) { + return specifier; + } + + const packagePath = path + .relative(packageInfo.directory, resolvedPath) + .replace(/\\/g, '/'); + if ( + packagePath.startsWith('../') || + !runtimeExtensions.includes(path.extname(packagePath)) + ) { + return specifier; + } + + return `${packageName}/${packagePath}`; +} + +export function resolveSourceEsmSpecifier(filePath: string, specifier: string) { + if (!specifier.startsWith('.')) { + return resolvePackageEsmSpecifier(filePath, specifier); + } + + const absoluteSpecifier = path.resolve(path.dirname(filePath), specifier); + if ( + fs.existsSync(absoluteSpecifier) && + fs.statSync(absoluteSpecifier).isFile() + ) { + return specifier; + } + + if (isSourceFile(absoluteSpecifier)) { + return `${specifier}.js`; + } + + if (isSourceDirectory(absoluteSpecifier)) { + return `${specifier.replace(/\/$/, '')}/index.js`; + } + + if (hasExtension(specifier)) { + return specifier; + } + + throw new Error( + `Cannot resolve native ESM import ${specifier} from ${path.relative(process.cwd(), filePath)}`, + ); +} + +function collectDeclarationFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return collectDeclarationFiles(entryPath); + } + + return entry.isFile() && + declarationExtensions.some((extension) => entry.name.endsWith(extension)) + ? [entryPath] + : []; + }); +} + +function resolveDeclarationSpecifier(filePath: string, specifier: string) { + if (!specifier.startsWith('.')) { + return specifier; + } + + const absoluteSpecifier = path.resolve(path.dirname(filePath), specifier); + if ( + fs.existsSync(absoluteSpecifier) && + fs.statSync(absoluteSpecifier).isFile() + ) { + return specifier; + } + + const fileExists = + fs.existsSync(`${absoluteSpecifier}.js`) || + declarationExtensions.some((extension) => + fs.existsSync(`${absoluteSpecifier}${extension}`), + ); + if (fileExists) { + return `${specifier}.js`; + } + + const indexExists = + fs.existsSync(path.join(absoluteSpecifier, 'index.js')) || + declarationExtensions.some((extension) => + fs.existsSync(path.join(absoluteSpecifier, `index${extension}`)), + ); + if (indexExists) { + return `${specifier.replace(/\/$/, '')}/index.js`; + } + + if (hasExtension(specifier)) { + return specifier; + } + + throw new Error( + `Cannot resolve native ESM declaration ${specifier} from ${path.relative(process.cwd(), filePath)}`, + ); +} + +function writeEsmPackageJson(directory: string) { + const packageJsonPath = path.join(directory, 'package.json'); + const packageJson = fs.existsSync(packageJsonPath) + ? fs.readJsonSync(packageJsonPath) + : {}; + + if (packageJson.type !== 'module') { + fs.writeJsonSync( + packageJsonPath, + { ...packageJson, type: 'module' }, + { spaces: 2 }, + ); + } +} + +export function finalizeNativeEsmOutput(directory: string) { + let rewriteCount = 0; + + collectDeclarationFiles(directory).forEach((filePath) => { + const source = fs.readFileSync(filePath, 'utf8'); + const rewrittenSource = source.replace( + moduleSpecifierPattern, + (match, prefix, quote, specifier, suffix) => { + const rewrittenSpecifier = resolveDeclarationSpecifier( + filePath, + specifier, + ); + if (rewrittenSpecifier !== specifier) { + rewriteCount += 1; + } + return `${prefix}${quote}${rewrittenSpecifier}${quote}${suffix}`; + }, + ); + + if (rewrittenSource !== source) { + fs.writeFileSync(filePath, rewrittenSource); + } + }); + + writeEsmPackageJson(directory); + return rewriteCount; +} diff --git a/test/nativeEsm.test.js b/test/nativeEsm.test.js new file mode 100644 index 0000000..a1ebc19 --- /dev/null +++ b/test/nativeEsm.test.js @@ -0,0 +1,372 @@ +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 { pathToFileURL } = require('node:url'); +const { afterEach, test } = require('node:test'); + +const { + finalizeNativeEsmOutput, + hasNativeEsmExport, + resolveSourceEsmSpecifier, +} = require('../dist/nativeEsm'); + +const fixtures = []; + +afterEach(() => { + fixtures.splice(0).forEach((fixture) => { + fs.rmSync(fixture, { recursive: true, force: true }); + }); +}); + +function createFixture() { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'father-plugin-esm-')); + fixtures.push(fixture); + return fixture; +} + +test('detects when package imports target the ESM output', () => { + assert.equal( + hasNativeEsmExport( + { + '.': { + import: './es/index.js', + require: './lib/index.js', + }, + }, + 'es', + ), + true, + ); + assert.equal( + hasNativeEsmExport( + { + '.': { + import: './lib/index.js', + require: './lib/index.js', + }, + }, + 'es', + ), + false, + ); + assert.equal(hasNativeEsmExport(undefined, 'es'), false); +}); + +test('resolves source files and directory entries to JavaScript specifiers', () => { + const fixture = createFixture(); + const sourceDirectory = path.join(fixture, 'src'); + const indexPath = path.join(sourceDirectory, 'index.ts'); + + fs.mkdirSync(path.join(sourceDirectory, 'nested'), { recursive: true }); + fs.writeFileSync(indexPath, ''); + fs.writeFileSync( + path.join(sourceDirectory, 'value.ts'), + 'export const value = 1;', + ); + fs.writeFileSync( + path.join(sourceDirectory, 'value.test.ts'), + 'export const dotted = 1;', + ); + fs.writeFileSync(path.join(sourceDirectory, 'style.css'), '.fixture {}'); + fs.writeFileSync( + path.join(sourceDirectory, 'nested', 'index.tsx'), + 'export const nested = 1;', + ); + + assert.equal(resolveSourceEsmSpecifier(indexPath, './value'), './value.js'); + assert.equal( + resolveSourceEsmSpecifier(indexPath, './value.test'), + './value.test.js', + ); + assert.equal( + resolveSourceEsmSpecifier(indexPath, './nested'), + './nested/index.js', + ); + assert.equal( + resolveSourceEsmSpecifier(indexPath, './style.css'), + './style.css', + ); + assert.equal(resolveSourceEsmSpecifier(indexPath, 'react'), 'react'); + assert.throws( + () => resolveSourceEsmSpecifier(indexPath, './missing'), + /Cannot resolve native ESM import/, + ); +}); + +test('completes legacy package subpaths without changing package exports', () => { + const fixture = createFixture(); + const sourceDirectory = path.join(fixture, 'src'); + const indexPath = path.join(sourceDirectory, 'index.ts'); + const legacyPackage = path.join(fixture, 'node_modules', 'legacy-package'); + const exportedPackage = path.join( + fixture, + 'node_modules', + 'exported-package', + ); + + fs.mkdirSync(path.join(legacyPackage, 'nested'), { recursive: true }); + fs.mkdirSync(exportedPackage, { recursive: true }); + fs.mkdirSync(sourceDirectory, { recursive: true }); + fs.writeFileSync(indexPath, ''); + fs.writeFileSync( + path.join(legacyPackage, 'package.json'), + JSON.stringify({ name: 'legacy-package' }), + ); + fs.writeFileSync( + path.join(legacyPackage, 'plugin.js'), + 'module.exports = {};', + ); + fs.writeFileSync( + path.join(legacyPackage, 'nested', 'index.js'), + 'module.exports = {};', + ); + fs.writeFileSync( + path.join(exportedPackage, 'package.json'), + JSON.stringify({ + name: 'exported-package', + exports: { './feature': './feature.js' }, + }), + ); + fs.writeFileSync( + path.join(exportedPackage, 'feature.js'), + 'export default {};', + ); + + assert.equal( + resolveSourceEsmSpecifier(indexPath, 'legacy-package/plugin'), + 'legacy-package/plugin.js', + ); + assert.equal( + resolveSourceEsmSpecifier(indexPath, 'legacy-package/nested'), + 'legacy-package/nested/index.js', + ); + assert.equal( + resolveSourceEsmSpecifier(indexPath, 'exported-package/feature'), + 'exported-package/feature', + ); + assert.equal( + resolveSourceEsmSpecifier(indexPath, 'legacy-package'), + 'legacy-package', + ); +}); + +test('rewrites declaration specifiers and marks the output as ESM', () => { + const fixture = createFixture(); + const outputDirectory = path.join(fixture, 'es'); + + fs.mkdirSync(path.join(outputDirectory, 'nested'), { recursive: true }); + fs.writeFileSync( + path.join(outputDirectory, 'value.js'), + 'export const value = 1;', + ); + fs.writeFileSync( + path.join(outputDirectory, 'value.d.ts'), + 'export declare const value = 1;', + ); + fs.writeFileSync( + path.join(outputDirectory, 'nested', 'index.js'), + 'export const nested = 1;', + ); + fs.writeFileSync( + path.join(outputDirectory, 'nested', 'index.d.ts'), + 'export declare const nested = 1;', + ); + fs.writeFileSync( + path.join(outputDirectory, 'nested', 'consumer.d.ts'), + "import type { nested } from '.';", + ); + fs.writeFileSync( + path.join(outputDirectory, 'index.d.ts'), + [ + "export { value } from './value';", + "export { nested } from './nested';", + "export type Value = import('./value').value;", + ].join('\n'), + ); + + assert.equal(finalizeNativeEsmOutput(outputDirectory), 4); + assert.equal( + fs.readFileSync(path.join(outputDirectory, 'index.d.ts'), 'utf8'), + [ + "export { value } from './value.js';", + "export { nested } from './nested/index.js';", + "export type Value = import('./value.js').value;", + ].join('\n'), + ); + assert.deepEqual( + JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'package.json'), 'utf8'), + ), + { type: 'module' }, + ); + assert.equal( + fs.readFileSync( + path.join(outputDirectory, 'nested', 'consumer.d.ts'), + 'utf8', + ), + "import type { nested } from './index.js';", + ); + assert.equal(finalizeNativeEsmOutput(outputDirectory), 0); +}); + +test('builds native ESM exports while preserving CommonJS output', async () => { + const fixture = createFixture(); + const pluginPath = path.resolve(__dirname, '../dist/index.js'); + const fatherBin = require.resolve('father/bin/father.js'); + const legacyPackage = path.join(fixture, 'node_modules', 'legacy-package'); + + fs.mkdirSync(path.join(fixture, 'src', 'nested'), { recursive: true }); + fs.mkdirSync(legacyPackage, { recursive: true }); + fs.writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ + name: 'father-plugin-native-esm-fixture', + exports: { + '.': { + types: './es/index.d.ts', + import: './es/index.js', + require: './lib/index.js', + }, + }, + }), + ); + fs.writeFileSync( + path.join(fixture, '.fatherrc.ts'), + `export default { plugins: [${JSON.stringify(pluginPath)}] };`, + ); + fs.writeFileSync( + path.join(fixture, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + declaration: true, + module: 'ESNext', + moduleResolution: 'Bundler', + skipLibCheck: true, + target: 'ES2018', + }, + }), + ); + fs.writeFileSync( + path.join(fixture, 'src', 'index.ts'), + [ + "export { default as legacy } from 'legacy-package/plugin';", + "export { nested } from './nested';", + "export { value } from './value';", + ].join('\n'), + ); + fs.writeFileSync( + path.join(fixture, 'src', 'value.ts'), + 'export const value = 1;', + ); + fs.writeFileSync( + path.join(fixture, 'src', 'nested', 'index.ts'), + 'export const nested = 2;', + ); + fs.writeFileSync( + path.join(legacyPackage, 'package.json'), + JSON.stringify({ name: 'legacy-package' }), + ); + fs.writeFileSync( + path.join(legacyPackage, 'plugin.js'), + 'module.exports = 3;', + ); + fs.writeFileSync( + path.join(legacyPackage, 'plugin.d.ts'), + 'declare const plugin: number; export default plugin;', + ); + + execFileSync(process.execPath, [fatherBin, 'build'], { + cwd: fixture, + env: { ...process.env, NO_COLOR: '1' }, + stdio: 'pipe', + }); + + const esmSource = fs.readFileSync( + path.join(fixture, 'es', 'index.js'), + 'utf8', + ); + const declarationSource = fs.readFileSync( + path.join(fixture, 'es', 'index.d.ts'), + 'utf8', + ); + assert.match(esmSource, /from ['"]legacy-package\/plugin\.js['"]/); + assert.match(esmSource, /from ['"]\.\/nested\/index\.js['"]/); + assert.match(esmSource, /from ['"]\.\/value\.js['"]/); + assert.match(declarationSource, /from ['"]\.\/nested\/index\.js['"]/); + assert.match(declarationSource, /from ['"]\.\/value\.js['"]/); + assert.deepEqual( + JSON.parse( + fs.readFileSync(path.join(fixture, 'es', 'package.json'), 'utf8'), + ), + { type: 'module' }, + ); + + const esm = await import(pathToFileURL(path.join(fixture, 'es', 'index.js'))); + const commonJS = require(path.join(fixture, 'lib', 'index.js')); + assert.deepEqual( + { legacy: esm.legacy, nested: esm.nested, value: esm.value }, + { legacy: 3, nested: 2, value: 1 }, + ); + assert.deepEqual( + { + legacy: commonJS.legacy, + nested: commonJS.nested, + value: commonJS.value, + }, + { legacy: 3, nested: 2, value: 1 }, + ); +}); + +test('keeps legacy bundler ESM output unchanged without an import export', () => { + const fixture = createFixture(); + const pluginPath = path.resolve(__dirname, '../dist/index.js'); + const fatherBin = require.resolve('father/bin/father.js'); + + fs.mkdirSync(path.join(fixture, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ + name: 'father-plugin-legacy-esm-fixture', + main: './lib/index.js', + module: './es/index.js', + }), + ); + fs.writeFileSync( + path.join(fixture, '.fatherrc.ts'), + `export default { plugins: [${JSON.stringify(pluginPath)}] };`, + ); + fs.writeFileSync( + path.join(fixture, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + declaration: true, + module: 'ESNext', + moduleResolution: 'Bundler', + skipLibCheck: true, + target: 'ES2018', + }, + }), + ); + fs.writeFileSync( + path.join(fixture, 'src', 'index.ts'), + "export { value } from './value';", + ); + fs.writeFileSync( + path.join(fixture, 'src', 'value.ts'), + 'export const value = 1;', + ); + + execFileSync(process.execPath, [fatherBin, 'build'], { + cwd: fixture, + env: { ...process.env, NO_COLOR: '1' }, + stdio: 'pipe', + }); + + assert.match( + fs.readFileSync(path.join(fixture, 'es', 'index.js'), 'utf8'), + /from ['"]\.\/value['"]/, + ); + assert.equal(fs.existsSync(path.join(fixture, 'es', 'package.json')), false); +});