Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/converter/convertFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ describe('convertFile', () => {
it('handles named imports from require statements', async () => {
await testFileConversion('cjs-destructuring');
});

it('handles package exports (legacy vs modern packages)', async () => {
await testFileConversion('package-exports');
});
});

describe('exports', () => {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/test/fixtures/package-exports/node_modules/lodash/omit.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/test/fixtures/package-exports/src/main.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import omit from 'lodash/omit.js';
import {HttpsError} from 'firebase-functions/v1/https';

const object = {a: 1, b: '2', c: 3};
omit(object, ['a', 'c']);

export function logError() {
console.log(HttpsError);
}
9 changes: 9 additions & 0 deletions src/test/fixtures/package-exports/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import omit from 'lodash/omit';
import {HttpsError} from 'firebase-functions/v1/https';

const object = {a: 1, b: '2', c: 3};
omit(object, ['a', 'c']);

export function logError() {
console.log(HttpsError);
}
12 changes: 12 additions & 0 deletions src/test/fixtures/package-exports/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
17 changes: 17 additions & 0 deletions src/util/PathUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
findBestMatch,
getNormalizedPath,
hasRelativePath,
hasPackageExports,
isMatchingPath,
removePathAlias,
removeWildCards,
Expand Down Expand Up @@ -94,4 +95,20 @@ describe('PathUtil', () => {
expect(hasRelativePath('vitest')).toBe(false);
});
});

describe('hasPackageExports', () => {
const packageExportsTestDir = '/home/runner/work/ts2esm/ts2esm/src/test/fixtures/package-exports/node_modules';

it('detects packages with exports field', () => {
expect(hasPackageExports(`${packageExportsTestDir}/firebase-functions`)).toBe(true);
});

it('detects packages without exports field', () => {
expect(hasPackageExports(`${packageExportsTestDir}/lodash`)).toBe(false);
});

it('returns false for non-existent packages', () => {
expect(hasPackageExports('/non/existent/path')).toBe(false);
});
});
});
13 changes: 13 additions & 0 deletions src/util/PathUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,16 @@ export function isNodeModuleRoot(directory: string): boolean {
const packageJsonExists = fs.existsSync(packageJsonPath);
return isInNodeModules && packageJsonExists;
}

export function hasPackageExports(packageDirectory: string): boolean {
try {
const packageJsonPath = path.join(packageDirectory, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return false;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
return !!packageJson.exports;
} catch {
return false;
}
}
25 changes: 24 additions & 1 deletion src/util/replaceModulePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {SourceFile, StringLiteral} from 'ts-morph';
import {ModuleInfo, parseInfo} from '../parser/InfoParser.js';
import {ProjectUtil} from './ProjectUtil.js';
import {toImport, toImportAttribute} from '../converter/ImportConverter.js';
import {getNormalizedPath, isNodeModuleRoot} from './PathUtil.js';
import {getNormalizedPath, isNodeModuleRoot, hasPackageExports} from './PathUtil.js';
import path from 'node:path';
import {PathFinder} from './PathFinder.js';

Expand All @@ -27,6 +27,17 @@ export function replaceModulePath({
return false;
}

function getPackageNameFromModulePath(modulePath: string): string {
// Handle scoped packages like @scope/package-name/subpath
if (modulePath.startsWith('@')) {
const parts = modulePath.split('/');
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : modulePath;
}
// Handle regular packages like package-name/subpath
const parts = modulePath.split('/');
return parts[0] || modulePath;
}

function createReplacementPath({
hasAttributesClause,
info,
Expand All @@ -44,6 +55,18 @@ function createReplacementPath({

const comesFromPathAlias = !!info.pathAlias && !!paths;
const isNodeModulesPath = !info.isRelative && info.normalized.includes('/') && !comesFromPathAlias;

// For node modules imports, check if the package has exports field
if (isNodeModulesPath) {
const packageName = getPackageNameFromModulePath(info.normalized);
const packageDirectory = path.join(projectDirectory, 'node_modules', packageName);

// If package has exports field, don't modify the import (modern package)
if (hasPackageExports(packageDirectory)) {
return null;
}
}

if (info.isRelative || comesFromPathAlias || isNodeModulesPath) {
if (['.json', '.css'].includes(info.extension)) {
return toImportAttribute(info);
Expand Down