diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index e60507643..53554bf0a 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -200,26 +200,9 @@ export function replaceImportPath( let relativePath = posix.relative(from, decodedImportPath); relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; - const nodeModulesText = 'node_modules'; - const nodeModulePos = relativePath.indexOf(nodeModulesText); - if (nodeModulePos >= 0) { - relativePath = relativePath.slice( - nodeModulePos + nodeModulesText.length + 1 // slash - ); - - const typesText = '@types'; - const typesPos = relativePath.indexOf(typesText); - if (typesPos >= 0) { - relativePath = relativePath.slice( - typesPos + typesText.length + 1 //slash - ); - } - - const indexText = '/index'; - const indexPos = relativePath.indexOf(indexText); - if (indexPos >= 0) { - relativePath = relativePath.slice(0, indexPos); - } + const normalizedPath = normalizePackagePath(relativePath); + if (normalizedPath !== relativePath) { + relativePath = normalizedPath; } else if (options.esmCompatible) { // Add appropriate extension for non-node_modules imports const extension = getOutputExtension(fileName); @@ -404,6 +387,42 @@ export function safeDecodeURIComponent(path: string) { } } +/** + * When a path goes through node_modules (e.g. a workspace package resolved to + * its physical location inside node_modules), strip the node_modules prefix so + * the generated import uses the package specifier instead of a relative path. + * This mirrors the same normalisation already done inside replaceImportPath(). + * + * For example: + * ../node_modules/@amk/utils/src/dto/order.dto → @amk/utils/src/dto/order.dto + * ../../../packages/product-warehouse/dist/index (no node_modules) → unchanged + */ +export function normalizePackagePath(importPath: string): string { + const nodeModulesText = 'node_modules'; + const nodeModulePos = importPath.indexOf(nodeModulesText); + if (nodeModulePos < 0) { + return importPath; + } + + let packagePath = importPath.slice( + nodeModulePos + nodeModulesText.length + 1 // skip the trailing slash + ); + + const typesText = '@types'; + const typesPos = packagePath.indexOf(typesText); + if (typesPos >= 0) { + packagePath = packagePath.slice(typesPos + typesText.length + 1); + } + + const indexText = '/index'; + const indexPos = packagePath.indexOf(indexText); + if (indexPos >= 0) { + packagePath = packagePath.slice(0, indexPos); + } + + return packagePath; +} + /** * Checks if a node can be directly referenced. * In the readonly mode, only literals can be referenced directly. diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts index 30b108b1e..a697e4af4 100644 --- a/lib/plugin/visitors/controller-class.visitor.ts +++ b/lib/plugin/visitors/controller-class.visitor.ts @@ -17,7 +17,8 @@ import { getDecoratorOrUndefinedByNames, getOutputExtension, getTypeReferenceAsString, - hasPropertyKey + hasPropertyKey, + normalizePackagePath } from '../utils/plugin-utils'; import { typeReferenceToIdentifier } from '../utils/type-reference-to-identifier.util'; import { AbstractFileVisitor } from './abstract.visitor'; @@ -42,7 +43,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor { Object.keys(this._collectedMetadata).forEach((filePath) => { const metadata = this._collectedMetadata[filePath]; const fileExt = options.esmCompatible ? getOutputExtension(filePath) : ''; - const path = filePath.replace(/\.[jt]s$/, fileExt); + let path = filePath.replace(/\.[jt]s$/, fileExt); + path = normalizePackagePath(path); const importExpr = ts.factory.createCallExpression( ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, undefined, diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts index e997874e1..e47962fc6 100644 --- a/lib/plugin/visitors/model-class.visitor.ts +++ b/lib/plugin/visitors/model-class.visitor.ts @@ -29,7 +29,8 @@ import { getTypeReferenceAsString, hasPropertyKey, isAutoGeneratedEnumUnion, - isAutoGeneratedTypeUnion + isAutoGeneratedTypeUnion, + normalizePackagePath } from '../utils/plugin-utils'; import { typeReferenceToIdentifier } from '../utils/type-reference-to-identifier.util'; import { AbstractFileVisitor } from './abstract.visitor'; @@ -51,7 +52,8 @@ export class ModelClassVisitor extends AbstractFileVisitor { Object.keys(this._collectedMetadata).forEach((filePath) => { const metadata = this._collectedMetadata[filePath]; const fileExt = options.esmCompatible ? getOutputExtension(filePath) : ''; - const path = filePath.replace(/\.[jt]s$/, fileExt); + let path = filePath.replace(/\.[jt]s$/, fileExt); + path = normalizePackagePath(path); const importExpr = ts.factory.createCallExpression( ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression, undefined, diff --git a/test/plugin/normalize-package-path.spec.ts b/test/plugin/normalize-package-path.spec.ts new file mode 100644 index 000000000..6285cb2f5 --- /dev/null +++ b/test/plugin/normalize-package-path.spec.ts @@ -0,0 +1,49 @@ +import { normalizePackagePath } from '../../lib/plugin/utils/plugin-utils'; + +describe('normalizePackagePath', () => { + it('should return relative paths without node_modules unchanged', () => { + expect(normalizePackagePath('./cats/dto/create-cat.dto')).toBe( + './cats/dto/create-cat.dto' + ); + }); + + it('should strip node_modules prefix for scoped workspace packages', () => { + // Before the fix this would remain as the relative node_modules path, + // causing TS error TS2742 when declaration files are emitted. + expect( + normalizePackagePath('../node_modules/@amk/utils/src/dto/order.dto') + ).toBe('@amk/utils/src/dto/order.dto'); + }); + + it('should strip node_modules prefix for unscoped packages', () => { + expect( + normalizePackagePath('../node_modules/some-lib/src/dto/foo.dto') + ).toBe('some-lib/src/dto/foo.dto'); + }); + + it('should strip @types prefix inside node_modules', () => { + expect( + normalizePackagePath('../node_modules/@types/express/index') + ).toBe('express'); + }); + + it('should strip /index suffix from package paths', () => { + expect( + normalizePackagePath('../node_modules/@org/product-warehouse/dist/index') + ).toBe('@org/product-warehouse/dist'); + }); + + it('should handle deeply nested node_modules paths', () => { + expect( + normalizePackagePath( + '../../../node_modules/@amk/utils/src/enum/payment-method.enum' + ) + ).toBe('@amk/utils/src/enum/payment-method.enum'); + }); + + it('should handle paths where node_modules is not present (non-workspace local paths)', () => { + expect( + normalizePackagePath('../../../packages/product-warehouse/dist/index') + ).toBe('../../../packages/product-warehouse/dist/index'); + }); +});