Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
| 包名 | `@rc-component/father-plugin` |
| 发布 | `@rc-component/np` / `rc-np` |

当包的 `exports.import` 指向 Father 的 ESM 输出时,插件会自动补全 Node 可解析的 JavaScript 路径、同步声明文件路径,并生成 ESM 类型标记。未声明原生 ESM 导出的包保持原有产物不变。

## 安装

```bash
Expand Down
33 changes: 33 additions & 0 deletions src/babelPluginAddEsmExtensions.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
27 changes: 24 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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') {
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down
258 changes: 258 additions & 0 deletions src/nativeEsm.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading