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
59 changes: 39 additions & 20 deletions lib/plugin/utils/plugin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions lib/plugin/visitors/controller-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions lib/plugin/visitors/model-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions test/plugin/normalize-package-path.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});