From 9e70bcbf5390e815a6844f1965b04056e5d8e670 Mon Sep 17 00:00:00 2001 From: Nick Messing Date: Wed, 19 Nov 2025 15:04:53 +0200 Subject: [PATCH] feat: add importExtension configuration option (#10510) * feat: add importExtension configuration option * feat: normalize import extension handling across plugins --------- Co-authored-by: Eddy Nguyen --- .changeset/sixty-sheep-tease.md | 10 ++ examples/typescript-esm/README.md | 4 +- packages/graphql-codegen-cli/src/codegen.ts | 15 +- packages/graphql-codegen-cli/src/config.ts | 33 ++-- .../tests/cli-flags.spec.ts | 67 ++++++++ .../visitor-plugin-common/src/base-visitor.ts | 18 ++- .../src/client-side-base-visitor.ts | 21 ++- .../visitor-plugin-common/src/imports.ts | 11 +- .../tests/client-side-base-visitor.spec.ts | 140 +++++++++++++++- .../gql-tag-operations/src/index.ts | 26 +-- .../client/src/fragment-masking-plugin.ts | 18 ++- packages/presets/client/src/index.ts | 12 +- .../client/tests/client-preset.spec.ts | 153 +++++++++++++++++- packages/presets/graphql-modules/src/index.ts | 9 +- .../graphql-modules/tests/integration.spec.ts | 64 ++++++++ packages/utils/plugins-helpers/src/helpers.ts | 18 +++ packages/utils/plugins-helpers/src/types.ts | 8 + website/public/config.schema.json | 27 ++++ .../docs/config-reference/codegen-config.mdx | 2 +- .../getting-started/esm-typescript-usage.mdx | 50 +++++- 20 files changed, 646 insertions(+), 60 deletions(-) create mode 100644 .changeset/sixty-sheep-tease.md diff --git a/.changeset/sixty-sheep-tease.md b/.changeset/sixty-sheep-tease.md new file mode 100644 index 00000000000..c317e916f88 --- /dev/null +++ b/.changeset/sixty-sheep-tease.md @@ -0,0 +1,10 @@ +--- +'@graphql-codegen/gql-tag-operations': minor +'@graphql-codegen/visitor-plugin-common': minor +'@graphql-codegen/graphql-modules-preset': minor +'@graphql-codegen/plugin-helpers': minor +'@graphql-codegen/cli': minor +'@graphql-codegen/client-preset': minor +--- + +add importExtension configuration option diff --git a/examples/typescript-esm/README.md b/examples/typescript-esm/README.md index 41eadc5178f..c5c0917551c 100644 --- a/examples/typescript-esm/README.md +++ b/examples/typescript-esm/README.md @@ -13,8 +13,8 @@ yarn start ## Explanation -In ESM the file extension `.js` must be appended to named imports. -This can be achieved by setting the codegen config `emitLegacyCommonJSImports` to `false` (see `codegen.yml`). +In ESM the file extension must be appended to named imports. +This can be achieved by setting the codegen config `importExtension` to `'.js'` or `'.ts'` (see `codegen.yml`). TypeScript introduced a new module resolution algorithm for ESM in version 4.7. We set the `moduleResolution` to `node16` and the (output) module type to `node16` (see `tsconfig.json`). Additionally, within the `package.json` we specify the `type` property with the value `module` in order to instruct Node.js, bundlers and other tools that all `.js` files within this folder should be treated as ESM modules. diff --git a/packages/graphql-codegen-cli/src/codegen.ts b/packages/graphql-codegen-cli/src/codegen.ts index 3eaffe8919e..e44c107486e 100644 --- a/packages/graphql-codegen-cli/src/codegen.ts +++ b/packages/graphql-codegen-cli/src/codegen.ts @@ -7,6 +7,7 @@ import { CodegenPlugin, getCachedDocumentNodeFromSchema, normalizeConfig, + normalizeImportExtension, normalizeInstanceOrArray, normalizeOutputParam, Types, @@ -14,7 +15,7 @@ import { import { NoTypeDefinitionsFound } from '@graphql-tools/load'; import { DocumentNode, GraphQLError, GraphQLSchema } from 'graphql'; import { Listr, ListrTask } from 'listr2'; -import { CodegenContext, ensureContext, shouldEmitLegacyCommonJSImports } from './config.js'; +import { CodegenContext, ensureContext } from './config.js'; import { getPluginByName } from './plugins.js'; import { getPresetByName } from './presets.js'; import { debugLog, printLogs } from './utils/debugging.js'; @@ -321,12 +322,18 @@ export async function executeCodegen( }) ); + const importExtension = normalizeImportExtension({ + emitLegacyCommonJSImports: config.emitLegacyCommonJSImports, + importExtension: config.importExtension, + }); + const mergedConfig = { ...rootConfig, ...(typeof outputFileTemplateConfig === 'string' ? { value: outputFileTemplateConfig } : outputFileTemplateConfig), - emitLegacyCommonJSImports: shouldEmitLegacyCommonJSImports(config), + importExtension, + emitLegacyCommonJSImports: config.emitLegacyCommonJSImports ?? true, }; const documentTransforms = Array.isArray(outputConfig.documentTransforms) @@ -377,8 +384,8 @@ export async function executeCodegen( const process = async (outputArgs: Types.GenerateOptions) => { const output = await codegen({ ...outputArgs, - // @ts-expect-error todo: fix 'emitLegacyCommonJSImports' does not exist in type 'GenerateOptions' - emitLegacyCommonJSImports: shouldEmitLegacyCommonJSImports(config, outputArgs.filename), + importExtension, + emitLegacyCommonJSImports: config.emitLegacyCommonJSImports ?? true, cache, }); result.push({ diff --git a/packages/graphql-codegen-cli/src/config.ts b/packages/graphql-codegen-cli/src/config.ts index 913056851a1..2ad8311458e 100644 --- a/packages/graphql-codegen-cli/src/config.ts +++ b/packages/graphql-codegen-cli/src/config.ts @@ -38,6 +38,7 @@ export type YamlCliFlags = { debug?: boolean; ignoreNoDocuments?: boolean; emitLegacyCommonJSImports?: boolean; + importExtension?: '' | `.${string}`; }; export function generateSearchPlaces(moduleName: string) { @@ -255,6 +256,18 @@ export function buildOptions() { type: 'boolean' as const, default: false, }, + 'emit-legacy-common-js-imports': { + describe: 'Emit legacy CommonJS imports (deprecated, use import-extension instead)', + type: 'boolean' as const, + }, + 'import-extension': { + describe: 'Extension to append to imports (e.g., .js, .mjs, or empty string for no extension)', + type: 'string' as const, + }, + 'ignore-no-documents': { + describe: 'Suppress errors for no documents', + type: 'boolean' as const, + }, }; } @@ -322,6 +335,10 @@ export function updateContextWithCliFlags(context: CodegenContext, cliFlags: Yam config.emitLegacyCommonJSImports = cliFlags['emit-legacy-common-js-imports'] === true; } + if (cliFlags['import-extension'] !== undefined) { + config.importExtension = cliFlags['import-extension']; + } + if (cliFlags.project) { context.useProject(cliFlags.project); } @@ -488,19 +505,3 @@ function addHashToDocumentFiles(documentFilesPromise: Promise { expect(config.emitLegacyCommonJSImports).toBeFalsy(); }); + it('Should set importExtension config using cli flags to .js', async () => { + mockConfig(` + schema: schema.graphql + generates: + file.ts: + - plugin + `); + const args = createArgv('--import-extension .js'); + const context = await createContext(parseArgv(args)); + const config = context.getConfig(); + expect(config.importExtension).toBe('.js'); + }); + + it('Should set importExtension config using cli flags to .mjs', async () => { + mockConfig(` + schema: schema.graphql + generates: + file.ts: + - plugin + `); + const args = createArgv('--import-extension .mjs'); + const context = await createContext(parseArgv(args)); + const config = context.getConfig(); + expect(config.importExtension).toBe('.mjs'); + }); + + it('Should set importExtension config using cli flags to empty string', async () => { + mockConfig(` + schema: schema.graphql + generates: + file.ts: + - plugin + `); + const args = createArgv('--import-extension ""'); + const context = await createContext(parseArgv(args)); + const config = context.getConfig(); + expect(config.importExtension).toBe(''); + }); + + it('Should overwrite importExtension from config using cli flags', async () => { + mockConfig(` + schema: schema.graphql + importExtension: .js + generates: + file.ts: + - plugin + `); + const args = createArgv('--import-extension .mjs'); + const context = await createContext(parseArgv(args)); + const config = context.getConfig(); + expect(config.importExtension).toBe('.mjs'); + }); + + it('Should overwrite importExtension config using cli flags to empty string', async () => { + mockConfig(` + schema: schema.graphql + importExtension: .js + generates: + file.ts: + - plugin + `); + const args = createArgv('--import-extension ""'); + const context = await createContext(parseArgv(args)); + const config = context.getConfig(); + expect(config.importExtension).toBe(''); + }); + it('Should overwrite ignoreNoDocuments config using cli flags to true', async () => { mockConfig(` schema: schema.graphql diff --git a/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts b/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts index 5f83d587e6c..45903d52576 100644 --- a/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts +++ b/packages/plugins/other/visitor-plugin-common/src/base-visitor.ts @@ -13,6 +13,7 @@ import { ScalarsMap, } from './types.js'; import { DeclarationBlockConfig } from './utils.js'; +import { normalizeImportExtension } from '@graphql-codegen/plugin-helpers'; export interface BaseVisitorConvertOptions { useTypesPrefix?: boolean; @@ -35,7 +36,8 @@ export interface ParsedConfig { useTypeImports: boolean; allowEnumStringTypes: boolean; inlineFragmentTypes: InlineFragmentTypeOptions; - emitLegacyCommonJSImports: boolean; + emitLegacyCommonJSImports?: boolean; + importExtension: '' | `.${string}`; printFieldsOnNewLines: boolean; includeExternalFragments: boolean; } @@ -360,11 +362,17 @@ export interface RawConfig { */ inlineFragmentTypes?: InlineFragmentTypeOptions; /** + * @deprecated Please use `importExtension` instead. * @default true * @description Emit legacy common js imports. * Default it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065). */ emitLegacyCommonJSImports?: boolean; + /** + * @description Append this extension to all imports. + * Useful for ESM environments that require file extensions in import statements. + */ + importExtension?: '' | `.${string}`; /** * @default false @@ -397,6 +405,10 @@ export class BaseVisitor) { + const importExtension = normalizeImportExtension({ + emitLegacyCommonJSImports: rawConfig.emitLegacyCommonJSImports, + importExtension: rawConfig.importExtension, + }); this._parsedConfig = { convert: convertFactory(rawConfig), typesPrefix: rawConfig.typesPrefix || '', @@ -408,8 +420,8 @@ export class BaseVisitor 0) { if (this.config.importDocumentNodeExternallyFrom === 'near-operation-file' && this._documents.length === 1) { let documentPath = `./${this.clearExtension(basename(this._documents[0].location))}`; - if (!this.config.emitLegacyCommonJSImports) { - documentPath += '.js'; - } + documentPath += normalizeImportExtension({ + emitLegacyCommonJSImports: this.config.emitLegacyCommonJSImports, + importExtension: this.config.importExtension, + }); this._imports.add(`import * as Operations from '${documentPath}';`); } else { @@ -668,6 +674,10 @@ export class ClientSideBaseVisitor< options.excludeFragments || this.config.globalNamespace || this.config.documentMode !== DocumentMode.graphQLTag; if (!excludeFragments) { + const importExtension = normalizeImportExtension({ + emitLegacyCommonJSImports: this.config.emitLegacyCommonJSImports, + importExtension: this.config.importExtension, + }); const deduplicatedImports = Object.values(groupBy(this.config.fragmentImports, fi => fi.importSource.path)) .map( (fragmentImports): ImportDeclaration => ({ @@ -680,6 +690,7 @@ export class ClientSideBaseVisitor< ), }, emitLegacyCommonJSImports: this.config.emitLegacyCommonJSImports, + importExtension, }) ) .filter(fragmentImport => fragmentImport.outputPath !== fragmentImport.importSource.path); diff --git a/packages/plugins/other/visitor-plugin-common/src/imports.ts b/packages/plugins/other/visitor-plugin-common/src/imports.ts index b9e4cbde6ba..f2054e3e05a 100644 --- a/packages/plugins/other/visitor-plugin-common/src/imports.ts +++ b/packages/plugins/other/visitor-plugin-common/src/imports.ts @@ -1,5 +1,6 @@ import { dirname, isAbsolute, join, relative, resolve } from 'path'; import parse from 'parse-filepath'; +import { normalizeImportExtension } from '@graphql-codegen/plugin-helpers'; export type ImportDeclaration = { outputPath: string; @@ -7,7 +8,8 @@ export type ImportDeclaration = { baseOutputDir: string; baseDir: string; typesImport: boolean; - emitLegacyCommonJSImports: boolean; + emitLegacyCommonJSImports?: boolean; + importExtension: '' | `.${string}`; }; export type ImportSource = { @@ -57,7 +59,12 @@ export function generateImportStatement(statement: ImportDeclaration): string { ? `{ ${Array.from(new Set(importSource.identifiers)).join(', ')} }` : '*'; const importExtension = - importPath.startsWith('/') || importPath.startsWith('.') ? (statement.emitLegacyCommonJSImports ? '' : '.js') : ''; + importPath.startsWith('/') || importPath.startsWith('.') + ? normalizeImportExtension({ + emitLegacyCommonJSImports: statement.emitLegacyCommonJSImports, + importExtension: statement.importExtension, + }) + : ''; const importAlias = importSource.namespace ? ` as ${importSource.namespace}` : ''; const importStatement = typesImport ? 'import type' : 'import'; return `${importStatement} ${importNames}${importAlias} from '${importPath}${importExtension}';${ diff --git a/packages/plugins/other/visitor-plugin-common/tests/client-side-base-visitor.spec.ts b/packages/plugins/other/visitor-plugin-common/tests/client-side-base-visitor.spec.ts index 79a47954a6f..cebe4d3d3c1 100644 --- a/packages/plugins/other/visitor-plugin-common/tests/client-side-base-visitor.spec.ts +++ b/packages/plugins/other/visitor-plugin-common/tests/client-side-base-visitor.spec.ts @@ -81,6 +81,142 @@ describe('getImports', () => { expect(imports[0]).toBe(`import * as Operations from './${fileName}.js';`); }); }); + + describe('when importExtension is set to .mjs', () => { + it('appends `.mjs` to Operations import path', () => { + const fileName = 'fooBarQuery'; + const importPath = `src/queries/${fileName}`; + + const document = parse( + `query fooBarQuery { + a { + foo + bar + } + } + ` + ); + + const visitor = new ClientSideBaseVisitor( + schema, + [], + { + importExtension: '.mjs', + importDocumentNodeExternallyFrom: 'near-operation-file', + documentMode: DocumentMode.external, + }, + {}, + [{ document, location: importPath }] + ); + + visitor.OperationDefinition(document.definitions[0] as OperationDefinitionNode); + + const imports = visitor.getImports(); + expect(imports[0]).toBe(`import * as Operations from './${fileName}.mjs';`); + }); + }); + + describe('when importExtension is set to empty string', () => { + it('does not append extension to Operations import path', () => { + const fileName = 'fooBarQuery'; + const importPath = `src/queries/${fileName}`; + + const document = parse( + `query fooBarQuery { + a { + foo + bar + } + } + ` + ); + + const visitor = new ClientSideBaseVisitor( + schema, + [], + { + importExtension: '', + importDocumentNodeExternallyFrom: 'near-operation-file', + documentMode: DocumentMode.external, + }, + {}, + [{ document, location: importPath }] + ); + + visitor.OperationDefinition(document.definitions[0] as OperationDefinitionNode); + + const imports = visitor.getImports(); + expect(imports[0]).toBe(`import * as Operations from './${fileName}';`); + }); + }); + + describe('when both importExtension and emitLegacyCommonJSImports are set', () => { + it('uses importExtension over emitLegacyCommonJSImports', () => { + const fileName = 'fooBarQuery'; + const importPath = `src/queries/${fileName}`; + + const document = parse( + `query fooBarQuery { + a { + foo + bar + } + } + ` + ); + + const visitor = new ClientSideBaseVisitor( + schema, + [], + { + importExtension: '.mjs', + emitLegacyCommonJSImports: false, + importDocumentNodeExternallyFrom: 'near-operation-file', + documentMode: DocumentMode.external, + }, + {}, + [{ document, location: importPath }] + ); + + visitor.OperationDefinition(document.definitions[0] as OperationDefinitionNode); + + const imports = visitor.getImports(); + expect(imports[0]).toBe(`import * as Operations from './${fileName}.mjs';`); + }); + + it('uses importExtension set to empty string even when emitLegacyCommonJSImports is false', () => { + const fileName = 'fooBarQuery'; + const importPath = `src/queries/${fileName}`; + + const document = parse( + `query fooBarQuery { + a { + foo + bar + } + } + ` + ); + + const visitor = new ClientSideBaseVisitor( + schema, + [], + { + importExtension: '', + emitLegacyCommonJSImports: false, + importDocumentNodeExternallyFrom: 'near-operation-file', + documentMode: DocumentMode.external, + }, + {}, + [{ document, location: importPath }] + ); + + visitor.OperationDefinition(document.definitions[0] as OperationDefinitionNode); + + const imports = visitor.getImports(); + expect(imports[0]).toBe(`import * as Operations from './${fileName}';`); + }); + }); }); describe('when documentMode "external", importDocumentNodeExternallyFrom is relative path', () => { @@ -185,7 +321,7 @@ describe('getImports', () => { { name: 'FieldsFragment', kind: 'type' }, ], }, - emitLegacyCommonJSImports: true, + importExtension: '', typesImport: false, }, ], @@ -258,7 +394,7 @@ describe('getImports', () => { { name: 'FieldsFragment', kind: 'type' }, ], }, - emitLegacyCommonJSImports: true, + importExtension: '', typesImport: false, }, ], diff --git a/packages/plugins/typescript/gql-tag-operations/src/index.ts b/packages/plugins/typescript/gql-tag-operations/src/index.ts index a0ace104577..9df7d6b6c9c 100644 --- a/packages/plugins/typescript/gql-tag-operations/src/index.ts +++ b/packages/plugins/typescript/gql-tag-operations/src/index.ts @@ -1,4 +1,4 @@ -import { PluginFunction } from '@graphql-codegen/plugin-helpers'; +import { normalizeImportExtension, PluginFunction } from '@graphql-codegen/plugin-helpers'; import { DocumentMode } from '@graphql-codegen/visitor-plugin-common'; import { Source } from '@graphql-tools/utils'; import { FragmentDefinitionNode, OperationDefinitionNode } from 'graphql'; @@ -28,6 +28,7 @@ export const plugin: PluginFunction<{ augmentedModuleName?: string; gqlTagName?: string; emitLegacyCommonJSImports?: boolean; + importExtension?: '' | `.${string}`; documentMode?: DocumentMode; }> = ( _, @@ -38,12 +39,17 @@ export const plugin: PluginFunction<{ augmentedModuleName, gqlTagName = 'gql', emitLegacyCommonJSImports, + importExtension, documentMode, }, _info ) => { + const appendedImportExtension = normalizeImportExtension({ + emitLegacyCommonJSImports, + importExtension, + }); if (documentMode === DocumentMode.string) { - const code = [`import * as types from './graphql${emitLegacyCommonJSImports ? '' : '.js'}';\n`, `\n`]; + const code = [`import * as types from './graphql${appendedImportExtension}';\n`, `\n`]; // We need the mapping from source as written to full document source to // handle fragments. An identity function would not suffice. @@ -55,9 +61,7 @@ export const plugin: PluginFunction<{ if (sourcesWithOperations.length > 0) { code.push( - [...getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'augmented', emitLegacyCommonJSImports), `\n`].join( - '' - ) + [...getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'augmented', appendedImportExtension), `\n`].join('') ); } @@ -72,7 +76,7 @@ export const plugin: PluginFunction<{ if (augmentedModuleName == null) { const code = [ - `import * as types from './graphql${emitLegacyCommonJSImports ? '' : '.js'}';\n`, + `import * as types from './graphql${appendedImportExtension}';\n`, `${ useTypeImports ? 'import type' : 'import' } { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';\n`, @@ -103,7 +107,7 @@ export const plugin: PluginFunction<{ if (sourcesWithOperations.length > 0) { code.push( - [...getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'lookup', emitLegacyCommonJSImports), `\n`].join('') + [...getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'lookup', appendedImportExtension), `\n`].join('') ); } @@ -126,7 +130,7 @@ export const plugin: PluginFunction<{ [ `\n`, ...(sourcesWithOperations.length > 0 - ? getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'augmented', emitLegacyCommonJSImports) + ? getGqlOverloadChunk(sourcesWithOperations, gqlTagName, 'augmented', appendedImportExtension) : []), `export function ${gqlTagName}(source: string): unknown;\n`, `\n`, @@ -182,7 +186,7 @@ function getGqlOverloadChunk( sourcesWithOperations: Array, gqlTagName: string, mode: Mode, - emitLegacyCommonJSImports?: boolean + importExtension: '' | `.${string}` ) { const lines = new Set(); @@ -193,9 +197,7 @@ function getGqlOverloadChunk( const returnType = mode === 'lookup' ? `(typeof documents)[${JSON.stringify(originalString)}]` - : emitLegacyCommonJSImports - ? `typeof import('./graphql').${operations[0].initialName}` - : `typeof import('./graphql.js').${operations[0].initialName}`; + : `typeof import('./graphql${importExtension}').${operations[0].initialName}`; lines.add( `/**\n * The ${gqlTagName} function is used to parse GraphQL queries into a document that can be used by GraphQL clients.\n */\n` + `export function ${gqlTagName}(source: ${JSON.stringify(originalString)}): ${returnType};\n` diff --git a/packages/presets/client/src/fragment-masking-plugin.ts b/packages/presets/client/src/fragment-masking-plugin.ts index f3e69e3c6bf..3e8fe55ce29 100644 --- a/packages/presets/client/src/fragment-masking-plugin.ts +++ b/packages/presets/client/src/fragment-masking-plugin.ts @@ -1,4 +1,4 @@ -import type { PluginFunction } from '@graphql-codegen/plugin-helpers'; +import { normalizeImportExtension, type PluginFunction } from '@graphql-codegen/plugin-helpers'; const fragmentTypeHelper = ` export type FragmentType> = TDocumentType extends DocumentTypeDecoration< @@ -128,20 +128,32 @@ export const plugin: PluginFunction<{ augmentedModuleName?: string; unmaskFunctionName?: string; emitLegacyCommonJSImports?: boolean; + importExtension?: '' | `.${string}`; isStringDocumentMode?: boolean; }> = ( _, __, - { useTypeImports, augmentedModuleName, unmaskFunctionName, emitLegacyCommonJSImports, isStringDocumentMode }, + { + useTypeImports, + augmentedModuleName, + unmaskFunctionName, + emitLegacyCommonJSImports, + importExtension, + isStringDocumentMode, + }, _info ) => { + const appendedImportExtension = normalizeImportExtension({ + emitLegacyCommonJSImports, + importExtension, + }); const documentNodeImport = `${useTypeImports ? 'import type' : 'import'} { ResultOf, DocumentTypeDecoration${ isStringDocumentMode ? '' : ', TypedDocumentNode' } } from '@graphql-typed-document-node/core';\n`; const deferFragmentHelperImports = `${useTypeImports ? 'import type' : 'import'} { Incremental${ isStringDocumentMode ? ', TypedDocumentString' : '' - } } from './graphql${emitLegacyCommonJSImports ? '' : '.js'}';\n`; + } } from './graphql${appendedImportExtension}';\n`; const fragmentDefinitionNodeImport = isStringDocumentMode ? '' diff --git a/packages/presets/client/src/index.ts b/packages/presets/client/src/index.ts index 73ee7dac239..a5c72a9d9a8 100644 --- a/packages/presets/client/src/index.ts +++ b/packages/presets/client/src/index.ts @@ -1,6 +1,6 @@ import * as addPlugin from '@graphql-codegen/add'; import * as gqlTagPlugin from '@graphql-codegen/gql-tag-operations'; -import type { PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; +import { normalizeImportExtension, type PluginFunction, type Types } from '@graphql-codegen/plugin-helpers'; import * as typedDocumentNodePlugin from '@graphql-codegen/typed-document-node'; import * as typescriptPlugin from '@graphql-codegen/typescript'; import * as typescriptOperationPlugin from '@graphql-codegen/typescript-operations'; @@ -251,6 +251,11 @@ export const preset: Types.OutputPreset = { let fragmentMaskingFileGenerateConfig: Types.GenerateOptions | null = null; + const importExtension = normalizeImportExtension({ + emitLegacyCommonJSImports: options.config.emitLegacyCommonJSImports, + importExtension: options.config.importExtension, + }); + if (isMaskingFragments === true) { const fragmentMaskingArtifactFileExtension = '.ts'; @@ -273,6 +278,7 @@ export const preset: Types.OutputPreset = { useTypeImports: options.config.useTypeImports, unmaskFunctionName: fragmentMaskingConfig.unmaskFunctionName, emitLegacyCommonJSImports: options.config.emitLegacyCommonJSImports, + importExtension, isStringDocumentMode: options.config.documentMode === DocumentMode.string, }, documents: [], @@ -282,8 +288,6 @@ export const preset: Types.OutputPreset = { let indexFileGenerateConfig: Types.GenerateOptions | null = null; - const reexportsExtension = options.config.emitLegacyCommonJSImports ? '' : '.js'; - if (reexports.length) { indexFileGenerateConfig = { filename: `${options.baseOutputDir}index.ts`, @@ -295,7 +299,7 @@ export const preset: Types.OutputPreset = { [`add`]: { content: reexports .sort() - .map(moduleName => `export * from "./${moduleName}${reexportsExtension}";`) + .map(moduleName => `export * from "./${moduleName}${importExtension}";`) .join('\n'), }, }, diff --git a/packages/presets/client/tests/client-preset.spec.ts b/packages/presets/client/tests/client-preset.spec.ts index fd330860548..11de702e352 100644 --- a/packages/presets/client/tests/client-preset.spec.ts +++ b/packages/presets/client/tests/client-preset.spec.ts @@ -1112,7 +1112,7 @@ export * from "./gql";`); preset, }, }, - emitLegacyCommonJSImports: false, + importExtension: '.js', }); expect(result).toHaveLength(4); @@ -1370,6 +1370,157 @@ export * from "./gql.js";`); }); }); + describe('importExtension configuration', () => { + it('generates imports with .mjs extension when importExtension is set to .mjs', async () => { + const { result } = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + 'out1/': { + preset, + }, + }, + importExtension: '.mjs', + }); + + expect(result).toHaveLength(4); + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./fragment-masking.mjs"; +export * from "./gql.mjs";`); + + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toContain(`import * as types from './graphql.mjs';`); + }); + + it('generates imports with no extension when importExtension is empty string', async () => { + const { result } = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + 'out1/': { + preset, + }, + }, + importExtension: '', + }); + + expect(result).toHaveLength(4); + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./fragment-masking"; +export * from "./gql";`); + + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toContain(`import * as types from './graphql';`); + }); + + it('uses importExtension over emitLegacyCommonJSImports when both are set', async () => { + const { result } = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + 'out1/': { + preset, + }, + }, + importExtension: '.mjs', + emitLegacyCommonJSImports: false, + }); + + expect(result).toHaveLength(4); + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + // Should use .mjs from importExtension, not .js from emitLegacyCommonJSImports: false + expect(indexFile.content).toEqual(`export * from "./fragment-masking.mjs"; +export * from "./gql.mjs";`); + + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toContain(`import * as types from './graphql.mjs';`); + }); + + it('uses importExtension set to empty string even when emitLegacyCommonJSImports is false', async () => { + const { result } = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + 'out1/': { + preset, + }, + }, + importExtension: '', + emitLegacyCommonJSImports: false, + }); + + expect(result).toHaveLength(4); + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + // Should use empty string from importExtension, not .js from emitLegacyCommonJSImports: false + expect(indexFile.content).toEqual(`export * from "./fragment-masking"; +export * from "./gql";`); + + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toContain(`import * as types from './graphql';`); + }); + + it('generates imports with custom .cjs extension when importExtension is set to .cjs', async () => { + const { result } = await executeCodegen({ + schema: [ + /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `, + ], + documents: path.join(__dirname, 'fixtures/simple-uppercase-operation-name.ts'), + generates: { + 'out1/': { + preset, + }, + }, + importExtension: '.cjs', + }); + + expect(result).toHaveLength(4); + const indexFile = result.find(file => file.filename === 'out1/index.ts'); + expect(indexFile.content).toEqual(`export * from "./fragment-masking.cjs"; +export * from "./gql.cjs";`); + + const gqlFile = result.find(file => file.filename === 'out1/gql.ts'); + expect(gqlFile.content).toContain(`import * as types from './graphql.cjs';`); + }); + }); + it('embed metadata in executable document node', async () => { const { result } = await executeCodegen({ schema: [ diff --git a/packages/presets/graphql-modules/src/index.ts b/packages/presets/graphql-modules/src/index.ts index bcfab88a76a..34fca6deb4d 100644 --- a/packages/presets/graphql-modules/src/index.ts +++ b/packages/presets/graphql-modules/src/index.ts @@ -1,5 +1,5 @@ import { join, relative, resolve } from 'path'; -import { Types } from '@graphql-codegen/plugin-helpers'; +import { normalizeImportExtension, Types } from '@graphql-codegen/plugin-helpers'; import { BaseVisitor, getConfigValue } from '@graphql-codegen/visitor-plugin-common'; import { concatAST, isScalarType } from 'graphql'; import { buildModule } from './builder.js'; @@ -72,10 +72,15 @@ export const preset: Types.OutputPreset = { documentTransforms: options.documentTransforms, }; + const importExtension = normalizeImportExtension({ + emitLegacyCommonJSImports: options.config.emitLegacyCommonJSImports, + importExtension: options.config.importExtension, + }); + const baseTypesFilename = baseTypesPath.replace( /\.(js|ts|d.ts)$/, // we need extension if ESM modules are used - options.config.emitLegacyCommonJSImports ? '' : '.js' + importExtension ); const baseTypesDir = stripFilename(baseOutput.filename); diff --git a/packages/presets/graphql-modules/tests/integration.spec.ts b/packages/presets/graphql-modules/tests/integration.spec.ts index 0bdff01efe9..82e4e5ec9ff 100644 --- a/packages/presets/graphql-modules/tests/integration.spec.ts +++ b/packages/presets/graphql-modules/tests/integration.spec.ts @@ -201,4 +201,68 @@ describe('Integration', () => { expect(result[3].content).toMatch(esmImportStatement); expect(result[4].content).toMatch(esmImportStatement); }); + + test('import paths should use importExtension when set to .mjs', async () => { + const withImportExtension = { + ...options, + importExtension: '.mjs' as const, + }; + const { result } = await executeCodegen(withImportExtension); + const importStatement = `import * as Types from "../global-types.mjs";`; + + expect(result.length).toBe(5); + expect(result[1].content).toMatch(importStatement); + expect(result[2].content).toMatch(importStatement); + expect(result[3].content).toMatch(importStatement); + expect(result[4].content).toMatch(importStatement); + }); + + test('import paths should use importExtension when set to empty string', async () => { + const withImportExtension = { + ...options, + importExtension: '' as const, + }; + const { result } = await executeCodegen(withImportExtension); + const importStatement = `import * as Types from "../global-types";`; + + expect(result.length).toBe(5); + expect(result[1].content).toMatch(importStatement); + expect(result[2].content).toMatch(importStatement); + expect(result[3].content).toMatch(importStatement); + expect(result[4].content).toMatch(importStatement); + }); + + test('importExtension should override emitLegacyCommonJSImports', async () => { + const withBothOptions = { + ...options, + importExtension: '.mjs' as const, + emitLegacyCommonJSImports: false, + }; + const { result } = await executeCodegen(withBothOptions); + const importStatement = `import * as Types from "../global-types.mjs";`; + + expect(result.length).toBe(5); + // Should use .mjs from importExtension, not .js from emitLegacyCommonJSImports + expect(result[1].content).toMatch(importStatement); + expect(result[2].content).toMatch(importStatement); + expect(result[3].content).toMatch(importStatement); + expect(result[4].content).toMatch(importStatement); + }); + + test('importExtension set to empty string should override emitLegacyCommonJSImports: false', async () => { + const withBothOptions = { + ...options, + importExtension: '' as const, + emitLegacyCommonJSImports: false, + }; + const { result } = await executeCodegen(withBothOptions); + const importStatement = `import * as Types from "../global-types";`; + + expect(result.length).toBe(5); + // Should use empty string from importExtension, not .js from emitLegacyCommonJSImports + expect(result[1].content).toMatch(importStatement); + expect(result[2].content).toMatch(importStatement); + expect(result[3].content).toMatch(importStatement); + expect(result[4].content).toMatch(importStatement); + }); }); diff --git a/packages/utils/plugins-helpers/src/helpers.ts b/packages/utils/plugins-helpers/src/helpers.ts index 2755a9cca1b..f91eec00bdd 100644 --- a/packages/utils/plugins-helpers/src/helpers.ts +++ b/packages/utils/plugins-helpers/src/helpers.ts @@ -224,3 +224,21 @@ export function isUsingTypes(document: DocumentNode, externalFragments: string[] return foundFields > 0; } + +export function normalizeImportExtension({ + emitLegacyCommonJSImports, + importExtension, +}: { + emitLegacyCommonJSImports: boolean | undefined; + importExtension: '' | `.${string}` | undefined; +}): '' | `.${string}` { + if (importExtension !== undefined) { + return importExtension; + } + + if (emitLegacyCommonJSImports === undefined || emitLegacyCommonJSImports === true) { + return ''; + } + + return '.js'; +} diff --git a/packages/utils/plugins-helpers/src/types.ts b/packages/utils/plugins-helpers/src/types.ts index 87bcedc15dd..322bdbacccb 100644 --- a/packages/utils/plugins-helpers/src/types.ts +++ b/packages/utils/plugins-helpers/src/types.ts @@ -20,6 +20,8 @@ export namespace Types { profiler?: Profiler; cache?(namespace: string, key: string, factory: () => Promise): Promise; documentTransforms?: ConfiguredDocumentTransform[]; + emitLegacyCommonJSImports?: boolean; + importExtension?: '' | `.${string}`; } export type FileOutput = { @@ -469,9 +471,15 @@ export namespace Types { */ ignoreNoDocuments?: boolean; /** + * @deprecated Please use `importExtension` instead. * @description A flag to disable adding `.js` extension to the output file. Default: `true`. */ emitLegacyCommonJSImports?: boolean; + /** + * @description Append this extension to all imports. + * Useful for ESM environments that require file extensions in import statements. + */ + importExtension?: '' | `.${string}`; /** * @description A flag to suppress printing errors when they occur. */ diff --git a/website/public/config.schema.json b/website/public/config.schema.json index c105a545acc..e286865a141 100644 --- a/website/public/config.schema.json +++ b/website/public/config.schema.json @@ -59,6 +59,13 @@ "description": "A flag to disable adding `.js` extension to the output file. Default: `true`.", "type": "boolean" }, + "importExtension": { + "description": "Append this extension to all imports.\nUseful for ESM environments that require file extensions in import statements.", + "anyOf": [ + { "type": "array", "items": { "type": "string" } }, + { "enum": [""], "type": "string" } + ] + }, "silent": { "description": "A flag to suppress printing errors when they occur.", "type": "boolean" }, "verbose": { "description": "A flag to output more detailed information about tasks", "type": "boolean" }, "debug": { "description": "A flag to output debug logs", "type": "boolean" }, @@ -656,6 +663,10 @@ "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" }, + "importExtension": { + "description": "Append this extension to all imports.\nUseful for ESM environments that require file extensions in import statements.", + "type": "string" + }, "extractAllFieldsToTypes": { "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", "type": "boolean" @@ -849,6 +860,10 @@ "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" }, + "importExtension": { + "description": "Append this extension to all imports.\nUseful for ESM environments that require file extensions in import statements.", + "type": "string" + }, "extractAllFieldsToTypes": { "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", "type": "boolean" @@ -1626,6 +1641,10 @@ "type": "boolean", "description": "If true, recursively goes through all object type's fields, checks if they have abstract types and generates expected types correctly.\nThis may not work for cases where provided default mapper types are also nested e.g. `defaultMapper: DeepPartial<{T}>` or `defaultMapper: Partial<{T}>`.\nDefault value: \"false\"" }, + "addInterfaceFieldResolverTypes": { + "description": "If true, add field resolver types to Interfaces.\nBy default, GraphQL Interfaces do not trigger any field resolvers,\nmeaning every implementing type must implement the same resolver for the shared fields.\n\nSome tools provide a way to change the default behaviour by making GraphQL Objects inherit\nmissing resolvers from their Interface types. In these cases, it is fine to turn this option to true.\n\nFor example, if you are using `@graphql-tools/schema#makeExecutableSchema` with `inheritResolversFromInterfaces: true`,\nyou can make `addInterfaceFieldResolverTypes: true` as well\nhttps://the-guild.dev/graphql/tools/docs/generate-schema#makeexecutableschema\nDefault value: \"false\"", + "type": "boolean" + }, "strictScalars": { "description": "Makes scalars strict.\n\nIf scalars are found in the schema that are not defined in `scalars`\nan error will be thrown during codegen.\nDefault value: \"false\"", "type": "boolean" @@ -1664,6 +1683,10 @@ "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" }, + "importExtension": { + "description": "Append this extension to all imports.\nUseful for ESM environments that require file extensions in import statements.", + "type": "string" + }, "extractAllFieldsToTypes": { "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", "type": "boolean" @@ -2789,6 +2812,10 @@ "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" }, + "importExtension": { + "description": "Append this extension to all imports.\nUseful for ESM environments that require file extensions in import statements.", + "type": "string" + }, "extractAllFieldsToTypes": { "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", "type": "boolean" diff --git a/website/src/pages/docs/config-reference/codegen-config.mdx b/website/src/pages/docs/config-reference/codegen-config.mdx index 68348da2b57..a8d6d76c866 100644 --- a/website/src/pages/docs/config-reference/codegen-config.mdx +++ b/website/src/pages/docs/config-reference/codegen-config.mdx @@ -78,7 +78,7 @@ Here are the supported options that you can define in the config file (see [sour - **`ignoreNoDocuments`** - A flag to not exit with non-zero exit code when there are no documents -- **`emitLegacyCommonJSImports`** - A flag to emit imports without `.js` extension. Enabled by default. +- **`importExtension`** - Append this extension to all imports. - **`errorsOnly`** - A flag to suppress printing anything except errors. diff --git a/website/src/pages/docs/getting-started/esm-typescript-usage.mdx b/website/src/pages/docs/getting-started/esm-typescript-usage.mdx index a3d0877c872..c5bfcb6bfd2 100644 --- a/website/src/pages/docs/getting-started/esm-typescript-usage.mdx +++ b/website/src/pages/docs/getting-started/esm-typescript-usage.mdx @@ -20,10 +20,14 @@ If you are impatient, [checkout this example](https://github.com/dotansimha/grap ## Codegen Configuration -One quirk of ESM is that named imports, such as `./foo` or `../something/from/here`, now require adding a `.js` at the end of the import statement. +One quirk of ESM is that named imports, such as `./foo` or `../something/from/here`, now require adding a file extension at the end of the import statement. For these examples, the correct ESM counterpart is `./foo.js` and `../something/from/here.js`. -In order to instruct GraphQL Code Generator to generate such imports whenever a named import is generated, you need to set the `emitLegacyCommonJSImports` option to `false`. +In order to instruct GraphQL Code Generator to generate such imports whenever a named import is generated, you need to set the `importExtension` option. + +### Standard ESM with TypeScript (transpiled to JavaScript) + +For most TypeScript projects that compile to JavaScript, you should use `.js` extensions: ```ts filename="codegen.ts" {6} import { CodegenConfig } from '@graphql-codegen/cli' @@ -31,7 +35,7 @@ import { CodegenConfig } from '@graphql-codegen/cli' const config: CodegenConfig = { schema: 'http://localhost:4000/graphql', documents: ['src/**/*.tsx'], - emitLegacyCommonJSImports: false, + importExtension: '.js', generates: { './src/gql/': { preset: 'client' @@ -42,6 +46,46 @@ const config: CodegenConfig = { export default config ``` +### Direct TypeScript Execution (Node.js 22.18.0+ or Deno) + +Since Node.js v22.18.0 (with type stripping enabled by default) and Deno, you can run TypeScript files directly without transpilation. In these environments, you can use `.ts` extensions in your imports: + +```ts filename="codegen.ts" {6} +import { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + schema: 'http://localhost:4000/graphql', + documents: ['src/**/*.tsx'], + importExtension: '.ts', + generates: { + './src/gql/': { + preset: 'client' + } + } +} + +export default config +``` + +With this configuration, the generated code will use `.ts` extensions in import statements: + +```ts +import { FragmentType } from './fragment-masking.ts' +import { graphql } from './gql.ts' +``` + +**When to use `.ts` extensions:** + +- You're using Node.js v22.18.0 or later with type stripping enabled (default) +- You're running your code with Deno +- You want to execute TypeScript files directly without a build step + +**When to use `.js` extensions:** + +- You're compiling TypeScript to JavaScript (most common case) +- You're using an older version of Node.js +- You have a build step in your workflow + ## TypeScript Compiler Options TypeScript introduced a new module resolution algorithm for ESM in version 4.7.