Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/core/src/generators/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { escapeRegExp } from '../utils/string';
interface GenerateImportsOptions {
imports: readonly GeneratorImport[];
namingConvention?: NamingConvention;
importExtension?: string;
}

export function generateImports({
imports,
namingConvention = NamingConvention.CAMEL_CASE,
importExtension = '',
}: GenerateImportsOptions) {
if (imports.length === 0) {
return '';
Expand All @@ -37,7 +39,8 @@ export function generateImports({
).map((imp) => ({
...imp,
importPath:
imp.importPath ?? `./${conventionName(imp.name, namingConvention)}`,
imp.importPath ??
`./${conventionName(imp.name, namingConvention)}${importExtension}`,
}));

const grouped = groupBy(normalized, (imp) =>
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,9 @@ export interface Tsconfig {
exactOptionalPropertyTypes?: boolean;
paths?: Record<string, string[]>;
target?: TsConfigTarget;
module?: string;
moduleResolution?: string;
allowImportingTsExtensions?: boolean;
};
}

Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/utils/tsconfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';

import type { Tsconfig } from '../types';
import { getImportExtension } from './tsconfig';

describe('getImportExtension', () => {
it('strips a .ts file extension when no tsconfig is provided', () => {
expect(getImportExtension('.ts')).toBe('');
expect(getImportExtension('.gen.ts')).toBe('.gen');
});

it('preserves non-.ts file extensions when no tsconfig is provided', () => {
expect(getImportExtension('.mjs')).toBe('.mjs');
});

it('keeps the file extension as-is when allowImportingTsExtensions is true', () => {
const tsconfig: Tsconfig = {
compilerOptions: { allowImportingTsExtensions: true },
};
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.ts');
expect(getImportExtension('.ts', tsconfig)).toBe('.ts');
});

it('rewrites .ts to .js when module is NodeNext', () => {
const tsconfig: Tsconfig = {
compilerOptions: { module: 'NodeNext' },
};
expect(getImportExtension('.ts', tsconfig)).toBe('.js');
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.js');
});

it('rewrites .ts to .js when moduleResolution is Node16', () => {
const tsconfig: Tsconfig = {
compilerOptions: { moduleResolution: 'Node16' },
};
expect(getImportExtension('.ts', tsconfig)).toBe('.js');
});

it('matches NodeNext/Node16 case-insensitively', () => {
expect(
getImportExtension('.ts', { compilerOptions: { module: 'nodenext' } }),
).toBe('.js');
expect(
getImportExtension('.ts', {
compilerOptions: { moduleResolution: 'node16' },
}),
).toBe('.js');
});

it('prefers allowImportingTsExtensions over NodeNext rewrites', () => {
const tsconfig: Tsconfig = {
compilerOptions: {
module: 'NodeNext',
allowImportingTsExtensions: true,
},
};
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen.ts');
});

it('falls back to stripping .ts for other module settings', () => {
const tsconfig: Tsconfig = {
compilerOptions: { module: 'ESNext' },
};
expect(getImportExtension('.ts', tsconfig)).toBe('');
expect(getImportExtension('.gen.ts', tsconfig)).toBe('.gen');
});
});
36 changes: 36 additions & 0 deletions packages/core/src/utils/tsconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,39 @@ export function isSyntheticDefaultImportsAllow(config?: Tsconfig) {
config.compilerOptions?.esModuleInterop
);
}

const NODE_NEXT_MODULES = new Set(['nodenext', 'node16']);

const NODE_NEXT_EXTENSION_MAP: readonly (readonly [string, string])[] = [
['.tsx', '.jsx'],
['.mts', '.mjs'],
['.cts', '.cjs'],
['.ts', '.js'],
];

export function getImportExtension(
fileExtension: string,
tsconfig?: Tsconfig,
): string {
const compilerOptions = tsconfig?.compilerOptions;

if (compilerOptions?.allowImportingTsExtensions) {
return fileExtension;
}

const module = compilerOptions?.module?.toLowerCase();
const moduleResolution = compilerOptions?.moduleResolution?.toLowerCase();
if (
(module && NODE_NEXT_MODULES.has(module)) ||
(moduleResolution && NODE_NEXT_MODULES.has(moduleResolution))
) {
for (const [from, to] of NODE_NEXT_EXTENSION_MAP) {
if (fileExtension.endsWith(from)) {
return `${fileExtension.slice(0, -from.length)}${to}`;
}
}
return fileExtension;
}

return fileExtension.replace(/\.ts$/, '') || '';
}
7 changes: 5 additions & 2 deletions packages/core/src/writers/generate-imports-for-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
GeneratorImport,
NormalizedOutputOptions,
} from '../types';
import { conventionName, isObject, upath } from '../utils';
import { conventionName, getImportExtension, isObject, upath } from '../utils';

export function generateImportsForBuilder(
output: NormalizedOutputOptions,
Expand Down Expand Up @@ -39,7 +39,10 @@ export function generateImportsForBuilder(
: (schemaImport.schemaName ?? schemaImport.name);
const normalizedName = conventionName(baseName, output.namingConvention);
const suffix = isZodSchemaOutput ? '.zod' : '';
const importExtension = output.fileExtension.replace(/\.ts$/, '') || '';
const importExtension = getImportExtension(
output.fileExtension,
output.tsconfig,
);
const dependency = upath.joinSafe(
relativeSchemasPath,
`${normalizedName}${suffix}${importExtension}`,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/writers/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,90 @@ describe('writeSchemas indexFiles', () => {
}
});

it('emits .js import suffixes when tsconfig module is NodeNext', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'orval-schema-nodenext-'),
);
const schemaPath = path.join(tempDir, 'schemas');

try {
await writeSchemas({
schemaPath,
schemas: [
createMockSchema('Pet'),
{
name: 'Owner',
model: 'export type Owner = { pet: Pet };',
imports: [{ name: 'Pet' }],
schema: {},
},
],
target: 'src/api',
namingConvention: NamingConvention.CAMEL_CASE,
fileExtension: '.ts',
header: '// nodenext',
indexFiles: true,
tsconfig: { compilerOptions: { module: 'NodeNext' } },
});

const ownerContent = await fs.readFile(
path.join(schemaPath, 'owner.ts'),
'utf8',
);
expect(ownerContent).toContain("from './pet.js';");

const indexContent = await fs.readFile(
path.join(schemaPath, 'index.ts'),
'utf8',
);
expect(indexContent).toContain("export * from './pet.js';");
expect(indexContent).toContain("export * from './owner.js';");
} finally {
await fs.remove(tempDir);
}
});

it('keeps the .ts file extension on imports when allowImportingTsExtensions is true', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'orval-schema-allow-ts-'),
);
const schemaPath = path.join(tempDir, 'schemas');

try {
await writeSchemas({
schemaPath,
schemas: [
createMockSchema('Pet'),
{
name: 'Owner',
model: 'export type Owner = { pet: Pet };',
imports: [{ name: 'Pet' }],
schema: {},
},
],
target: 'src/api',
namingConvention: NamingConvention.CAMEL_CASE,
fileExtension: '.gen.ts',
header: '// allowImportingTsExtensions',
indexFiles: true,
tsconfig: {
compilerOptions: {
module: 'NodeNext',
allowImportingTsExtensions: true,
},
},
});

const ownerContent = await fs.readFile(
path.join(schemaPath, 'owner.gen.ts'),
'utf8',
);
expect(ownerContent).toContain("from './pet.gen.ts';");
} finally {
await fs.remove(tempDir);
}
});

it('normalizes imports to schema name canonical file when importPath is stale', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'orval-schema-import-normalize-'),
Expand Down
Loading
Loading