Skip to content

Commit e3b888e

Browse files
authored
fix: file extension is not added to imported files when using NodeNext moduleResolution (#3361)
* fix: file extension is not added to imported files when using NodeNext moduleResolution * fix: file extension is not added to imported files w/ NodeNext - CodeRabbit suggestions * fix: file extension is not added to imported files w/ NodeNext - lint fix, tests
1 parent 06c44c1 commit e3b888e

8 files changed

Lines changed: 242 additions & 25 deletions

File tree

packages/core/src/generators/imports.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { escapeRegExp } from '../utils/string';
1313
interface GenerateImportsOptions {
1414
imports: readonly GeneratorImport[];
1515
namingConvention?: NamingConvention;
16+
importExtension?: string;
1617
}
1718

1819
export function generateImports({
1920
imports,
2021
namingConvention = NamingConvention.CAMEL_CASE,
22+
importExtension = '',
2123
}: GenerateImportsOptions) {
2224
if (imports.length === 0) {
2325
return '';
@@ -37,7 +39,8 @@ export function generateImports({
3739
).map((imp) => ({
3840
...imp,
3941
importPath:
40-
imp.importPath ?? `./${conventionName(imp.name, namingConvention)}`,
42+
imp.importPath ??
43+
`./${conventionName(imp.name, namingConvention)}${importExtension}`,
4144
}));
4245

4346
const grouped = groupBy(normalized, (imp) =>

packages/core/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,9 @@ export interface Tsconfig {
990990
exactOptionalPropertyTypes?: boolean;
991991
paths?: Record<string, string[]>;
992992
target?: TsConfigTarget;
993+
module?: string;
994+
moduleResolution?: string;
995+
allowImportingTsExtensions?: boolean;
993996
};
994997
}
995998

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { Tsconfig } from '../types';
4+
import { getImportExtension } from './tsconfig';
5+
6+
describe('getImportExtension', () => {
7+
it('strips a .ts file extension when no tsconfig is provided', () => {
8+
expect(getImportExtension('.ts')).toBe('');
9+
expect(getImportExtension('.gen.ts')).toBe('.gen');
10+
});
11+
12+
it('preserves non-.ts file extensions when no tsconfig is provided', () => {
13+
expect(getImportExtension('.mjs')).toBe('.mjs');
14+
});
15+
16+
it('keeps the file extension as-is when allowImportingTsExtensions is true', () => {
17+
const tsconfig: Tsconfig = {
18+
compilerOptions: { allowImportingTsExtensions: true },
19+
};
20+
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.ts');
21+
expect(getImportExtension('.ts', tsconfig)).toBe('.ts');
22+
});
23+
24+
it('rewrites .ts to .js when module is NodeNext', () => {
25+
const tsconfig: Tsconfig = {
26+
compilerOptions: { module: 'NodeNext' },
27+
};
28+
expect(getImportExtension('.ts', tsconfig)).toBe('.js');
29+
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.js');
30+
});
31+
32+
it('rewrites .ts to .js when moduleResolution is Node16', () => {
33+
const tsconfig: Tsconfig = {
34+
compilerOptions: { moduleResolution: 'Node16' },
35+
};
36+
expect(getImportExtension('.ts', tsconfig)).toBe('.js');
37+
});
38+
39+
it('matches NodeNext/Node16 case-insensitively', () => {
40+
expect(
41+
getImportExtension('.ts', { compilerOptions: { module: 'nodenext' } }),
42+
).toBe('.js');
43+
expect(
44+
getImportExtension('.ts', {
45+
compilerOptions: { moduleResolution: 'node16' },
46+
}),
47+
).toBe('.js');
48+
});
49+
50+
it('prefers allowImportingTsExtensions over NodeNext rewrites', () => {
51+
const tsconfig: Tsconfig = {
52+
compilerOptions: {
53+
module: 'NodeNext',
54+
allowImportingTsExtensions: true,
55+
},
56+
};
57+
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.ts');
58+
});
59+
60+
it('falls back to stripping .ts for other module settings', () => {
61+
const tsconfig: Tsconfig = {
62+
compilerOptions: { module: 'ESNext' },
63+
};
64+
expect(getImportExtension('.ts', tsconfig)).toBe('');
65+
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen');
66+
});
67+
});

packages/core/src/utils/tsconfig.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,39 @@ export function isSyntheticDefaultImportsAllow(config?: Tsconfig) {
1010
config.compilerOptions?.esModuleInterop
1111
);
1212
}
13+
14+
const NODE_NEXT_MODULES = new Set(['nodenext', 'node16']);
15+
16+
const NODE_NEXT_EXTENSION_MAP: readonly (readonly [string, string])[] = [
17+
['.tsx', '.jsx'],
18+
['.mts', '.mjs'],
19+
['.cts', '.cjs'],
20+
['.ts', '.js'],
21+
];
22+
23+
export function getImportExtension(
24+
fileExtension: string,
25+
tsconfig?: Tsconfig,
26+
): string {
27+
const compilerOptions = tsconfig?.compilerOptions;
28+
29+
if (compilerOptions?.allowImportingTsExtensions) {
30+
return fileExtension;
31+
}
32+
33+
const module = compilerOptions?.module?.toLowerCase();
34+
const moduleResolution = compilerOptions?.moduleResolution?.toLowerCase();
35+
if (
36+
(module && NODE_NEXT_MODULES.has(module)) ||
37+
(moduleResolution && NODE_NEXT_MODULES.has(moduleResolution))
38+
) {
39+
for (const [from, to] of NODE_NEXT_EXTENSION_MAP) {
40+
if (fileExtension.endsWith(from)) {
41+
return `${fileExtension.slice(0, -from.length)}${to}`;
42+
}
43+
}
44+
return fileExtension;
45+
}
46+
47+
return fileExtension.replace(/\.ts$/, '') || '';
48+
}

packages/core/src/writers/generate-imports-for-builder.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
GeneratorImport,
66
NormalizedOutputOptions,
77
} from '../types';
8-
import { conventionName, isObject, upath } from '../utils';
8+
import { conventionName, getImportExtension, isObject, upath } from '../utils';
99

1010
export function generateImportsForBuilder(
1111
output: NormalizedOutputOptions,
@@ -39,7 +39,10 @@ export function generateImportsForBuilder(
3939
: (schemaImport.schemaName ?? schemaImport.name);
4040
const normalizedName = conventionName(baseName, output.namingConvention);
4141
const suffix = isZodSchemaOutput ? '.zod' : '';
42-
const importExtension = output.fileExtension.replace(/\.ts$/, '') || '';
42+
const importExtension = getImportExtension(
43+
output.fileExtension,
44+
output.tsconfig,
45+
);
4346
const dependency = upath.joinSafe(
4447
relativeSchemasPath,
4548
`${normalizedName}${suffix}${importExtension}`,

packages/core/src/writers/schemas.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,90 @@ describe('writeSchemas indexFiles', () => {
698698
}
699699
});
700700

701+
it('emits .js import suffixes when tsconfig module is NodeNext', async () => {
702+
const tempDir = await fs.mkdtemp(
703+
path.join(os.tmpdir(), 'orval-schema-nodenext-'),
704+
);
705+
const schemaPath = path.join(tempDir, 'schemas');
706+
707+
try {
708+
await writeSchemas({
709+
schemaPath,
710+
schemas: [
711+
createMockSchema('Pet'),
712+
{
713+
name: 'Owner',
714+
model: 'export type Owner = { pet: Pet };',
715+
imports: [{ name: 'Pet' }],
716+
schema: {},
717+
},
718+
],
719+
target: 'src/api',
720+
namingConvention: NamingConvention.CAMEL_CASE,
721+
fileExtension: '.ts',
722+
header: '// nodenext',
723+
indexFiles: true,
724+
tsconfig: { compilerOptions: { module: 'NodeNext' } },
725+
});
726+
727+
const ownerContent = await fs.readFile(
728+
path.join(schemaPath, 'owner.ts'),
729+
'utf8',
730+
);
731+
expect(ownerContent).toContain("from './pet.js';");
732+
733+
const indexContent = await fs.readFile(
734+
path.join(schemaPath, 'index.ts'),
735+
'utf8',
736+
);
737+
expect(indexContent).toContain("export * from './pet.js';");
738+
expect(indexContent).toContain("export * from './owner.js';");
739+
} finally {
740+
await fs.remove(tempDir);
741+
}
742+
});
743+
744+
it('keeps the .ts file extension on imports when allowImportingTsExtensions is true', async () => {
745+
const tempDir = await fs.mkdtemp(
746+
path.join(os.tmpdir(), 'orval-schema-allow-ts-'),
747+
);
748+
const schemaPath = path.join(tempDir, 'schemas');
749+
750+
try {
751+
await writeSchemas({
752+
schemaPath,
753+
schemas: [
754+
createMockSchema('Pet'),
755+
{
756+
name: 'Owner',
757+
model: 'export type Owner = { pet: Pet };',
758+
imports: [{ name: 'Pet' }],
759+
schema: {},
760+
},
761+
],
762+
target: 'src/api',
763+
namingConvention: NamingConvention.CAMEL_CASE,
764+
fileExtension: '.gen.ts',
765+
header: '// allowImportingTsExtensions',
766+
indexFiles: true,
767+
tsconfig: {
768+
compilerOptions: {
769+
module: 'NodeNext',
770+
allowImportingTsExtensions: true,
771+
},
772+
},
773+
});
774+
775+
const ownerContent = await fs.readFile(
776+
path.join(schemaPath, 'owner.gen.ts'),
777+
'utf8',
778+
);
779+
expect(ownerContent).toContain("from './pet.gen.ts';");
780+
} finally {
781+
await fs.remove(tempDir);
782+
}
783+
});
784+
701785
it('normalizes imports to schema name canonical file when importPath is stale', async () => {
702786
const tempDir = await fs.mkdtemp(
703787
path.join(os.tmpdir(), 'orval-schema-import-normalize-'),

0 commit comments

Comments
 (0)