diff --git a/src/converter/convertFile.test.ts b/src/converter/convertFile.test.ts index 53b0ef0..704e7da 100644 --- a/src/converter/convertFile.test.ts +++ b/src/converter/convertFile.test.ts @@ -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', () => { diff --git a/src/test/fixtures/package-exports/node_modules/firebase-functions/package.json b/src/test/fixtures/package-exports/node_modules/firebase-functions/package.json new file mode 100644 index 0000000..b1366ab --- /dev/null +++ b/src/test/fixtures/package-exports/node_modules/firebase-functions/package.json @@ -0,0 +1,24 @@ +{ + "name": "firebase-functions", + "version": "4.5.0", + "description": "Firebase SDK for Cloud Functions", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "import": "./lib/index.mjs", + "types": "./lib/index.d.ts" + }, + "./v1": { + "require": "./lib/v1/index.js", + "import": "./lib/v1/index.mjs", + "types": "./lib/v1/index.d.ts" + }, + "./v1/https": { + "require": "./lib/v1/https.js", + "import": "./lib/v1/https.mjs", + "types": "./lib/v1/https.d.ts" + } + } +} \ No newline at end of file diff --git a/src/test/fixtures/package-exports/node_modules/lodash/omit.js b/src/test/fixtures/package-exports/node_modules/lodash/omit.js new file mode 100644 index 0000000..afdecdc --- /dev/null +++ b/src/test/fixtures/package-exports/node_modules/lodash/omit.js @@ -0,0 +1,5 @@ +// Mock lodash omit function +module.exports = function omit(object, paths) { + // Mock implementation + return {}; +}; \ No newline at end of file diff --git a/src/test/fixtures/package-exports/node_modules/lodash/package.json b/src/test/fixtures/package-exports/node_modules/lodash/package.json new file mode 100644 index 0000000..30ae707 --- /dev/null +++ b/src/test/fixtures/package-exports/node_modules/lodash/package.json @@ -0,0 +1,9 @@ +{ + "name": "lodash", + "version": "4.17.21", + "description": "Lodash modular utilities.", + "main": "lodash.js", + "keywords": ["modules", "stdlib", "util"], + "homepage": "https://lodash.com/", + "repository": "lodash/lodash" +} \ No newline at end of file diff --git a/src/test/fixtures/package-exports/src/main.snap.ts b/src/test/fixtures/package-exports/src/main.snap.ts new file mode 100644 index 0000000..bd8a2b8 --- /dev/null +++ b/src/test/fixtures/package-exports/src/main.snap.ts @@ -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); +} \ No newline at end of file diff --git a/src/test/fixtures/package-exports/src/main.ts b/src/test/fixtures/package-exports/src/main.ts new file mode 100644 index 0000000..597d70e --- /dev/null +++ b/src/test/fixtures/package-exports/src/main.ts @@ -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); +} \ No newline at end of file diff --git a/src/test/fixtures/package-exports/tsconfig.json b/src/test/fixtures/package-exports/tsconfig.json new file mode 100644 index 0000000..fd78b7d --- /dev/null +++ b/src/test/fixtures/package-exports/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/src/util/PathUtil.test.ts b/src/util/PathUtil.test.ts index 0b70907..345291f 100644 --- a/src/util/PathUtil.test.ts +++ b/src/util/PathUtil.test.ts @@ -2,6 +2,7 @@ import { findBestMatch, getNormalizedPath, hasRelativePath, + hasPackageExports, isMatchingPath, removePathAlias, removeWildCards, @@ -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); + }); + }); }); diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index 15c1dae..1fc3079 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -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; + } +} diff --git a/src/util/replaceModulePath.ts b/src/util/replaceModulePath.ts index eff24cb..f15977a 100644 --- a/src/util/replaceModulePath.ts +++ b/src/util/replaceModulePath.ts @@ -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'; @@ -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, @@ -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);