Skip to content

Commit 33f7e9d

Browse files
authored
feat: add automatic project setup detection to generator lifecycle (#344)
1 parent ff64693 commit 33f7e9d

44 files changed

Lines changed: 915 additions & 272 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/configurations.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ export default {
8989
| `".ts"` | Modern ESM with TypeScript sources | `moduleResolution: "node16"/"nodenext"` + `allowImportingTsExtensions: true` |
9090
| `".js"` | Compiled ESM output | `moduleResolution: "node16"/"nodenext"` (without `allowImportingTsExtensions`) |
9191

92+
#### Automatic Detection
93+
94+
When `importExtension` is not explicitly set in your configuration, the codegen automatically detects the appropriate setting based on your project setup. This enables zero-configuration support for most projects.
95+
96+
**Detection priority:**
97+
98+
1. **Bundler config files** (`vite.config.ts`, `webpack.config.js`, `next.config.js`, etc.) → `"none"`
99+
2. **Bundler in dependencies** (vite, webpack, esbuild, rollup, parcel) → `"none"`
100+
3. **`moduleResolution: "bundler"`** in tsconfig.json → `"none"`
101+
4. **`moduleResolution: "node16"/"nodenext"` + `allowImportingTsExtensions: true`**`".ts"`
102+
5. **`moduleResolution: "node16"/"nodenext"`** (without allowImportingTsExtensions) → `".js"`
103+
6. **Otherwise** → Uses default (`"none"`)
104+
105+
When detection occurs, you'll see an info message:
106+
```
107+
Auto-detected importExtension: '.js'
108+
```
109+
110+
**Note:** You can always override automatic detection by explicitly setting `importExtension` in your configuration.
111+
112+
**Limitation:** Automatic detection only affects `channels` and `client` generators. The `payloads`, `parameters`, `headers`, and `models` generators use Modelina which doesn't currently support import extensions.
113+
92114
#### Per-Generator Override
93115

94116
You can override the global setting for individual generators:

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@oclif/plugin-version": "^2.0.17",
2121
"@readme/openapi-parser": "^5.0.1",
2222
"chokidar": "^4.0.3",
23+
"comment-json": "^4.6.2",
2324
"cosmiconfig": "^9.0.0",
2425
"graphology": "^0.26.0",
2526
"inquirer": "^8.2.6",

src/codegen/configurations.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
createConfigValidationError,
4141
parseZodErrors
4242
} from './errors';
43+
import {detectTypeScriptImportExtension} from './detection';
4344
const moduleName = 'codegen';
4445
const explorer = cosmiconfig(moduleName, {
4546
searchPlaces: [
@@ -271,6 +272,17 @@ export async function realizeGeneratorContext(
271272
configFile: string | undefined
272273
): Promise<RunGeneratorContext> {
273274
const {config, filePath} = await loadAndRealizeConfigFile(configFile);
275+
276+
// Apply automatic project detection if importExtension not explicitly set
277+
if (config.importExtension === undefined) {
278+
const projectDir = path.dirname(filePath);
279+
const detected = await detectTypeScriptImportExtension(projectDir);
280+
if (detected !== null) {
281+
config.importExtension = detected;
282+
Logger.info(`Auto-detected importExtension: '${detected}'`);
283+
}
284+
}
285+
274286
Logger.debug(`Found configuration: ${JSON.stringify(config, null, 2)}`);
275287
const documentPath = path.resolve(path.dirname(filePath), config.inputPath);
276288
Logger.verbose(`Document path: ${documentPath}`);

src/codegen/detection.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* Project settings detection for automatic configuration.
3+
* Detects TypeScript module resolution settings and bundler presence
4+
* to set intelligent defaults for importExtension.
5+
*/
6+
import {existsSync, readFileSync} from 'fs';
7+
import path from 'path';
8+
import {parse as parseJsonc} from 'comment-json';
9+
import {Logger} from '../LoggingInterface';
10+
import type {ImportExtension} from './utils';
11+
12+
interface TsConfig {
13+
compilerOptions?: {
14+
moduleResolution?: string;
15+
module?: string;
16+
allowImportingTsExtensions?: boolean;
17+
verbatimModuleSyntax?: boolean;
18+
};
19+
}
20+
21+
interface PackageJson {
22+
type?: 'module' | 'commonjs';
23+
dependencies?: Record<string, string>;
24+
devDependencies?: Record<string, string>;
25+
}
26+
27+
/** Bundler packages that indicate no extension is needed */
28+
const BUNDLER_DEPS = new Set([
29+
'webpack',
30+
'vite',
31+
'esbuild',
32+
'rollup',
33+
'parcel',
34+
'turbopack',
35+
'next',
36+
'@rspack/core'
37+
]);
38+
39+
/** Bundler config files that indicate no extension is needed */
40+
const BUNDLER_CONFIGS = [
41+
'vite.config.ts',
42+
'vite.config.js',
43+
'vite.config.mjs',
44+
'webpack.config.js',
45+
'webpack.config.ts',
46+
'webpack.config.mjs',
47+
'rollup.config.js',
48+
'rollup.config.ts',
49+
'rollup.config.mjs',
50+
'next.config.js',
51+
'next.config.ts',
52+
'next.config.mjs',
53+
'esbuild.config.js',
54+
'esbuild.config.mjs',
55+
'rspack.config.js',
56+
'rspack.config.ts',
57+
'rspack.config.mjs'
58+
];
59+
60+
/**
61+
* Safely read and parse JSON file, returning null on any error.
62+
* For tsconfig.json, uses JSONC parsing (via comment-json) to handle comments and trailing commas.
63+
*/
64+
function readJsonFile<T>(filePath: string, allowJsonc = false): T | null {
65+
try {
66+
const content = readFileSync(filePath, 'utf8');
67+
if (allowJsonc) {
68+
return parseJsonc(content) as T;
69+
}
70+
return JSON.parse(content) as T;
71+
} catch {
72+
return null;
73+
}
74+
}
75+
76+
/**
77+
* Check for bundler config files in the project directory.
78+
*/
79+
function detectBundlerConfigFile(projectDir: string): string | null {
80+
for (const configFile of BUNDLER_CONFIGS) {
81+
const configPath = path.join(projectDir, configFile);
82+
if (existsSync(configPath)) {
83+
return configFile;
84+
}
85+
}
86+
return null;
87+
}
88+
89+
/**
90+
* Check for bundler dependencies in package.json.
91+
*/
92+
function detectBundlerDependency(packageJson: PackageJson): string | null {
93+
const allDeps = Object.keys({
94+
...packageJson.dependencies,
95+
...packageJson.devDependencies
96+
});
97+
98+
for (const dep of allDeps) {
99+
if (BUNDLER_DEPS.has(dep)) {
100+
return dep;
101+
}
102+
}
103+
return null;
104+
}
105+
106+
/**
107+
* Determine import extension from tsconfig moduleResolution setting.
108+
* If moduleResolution is undefined, infers it from the module setting per TypeScript rules.
109+
*/
110+
function detectFromModuleResolution(
111+
moduleRes: string | undefined,
112+
module: string | undefined,
113+
allowTsExtensions: boolean | undefined
114+
): ImportExtension | null {
115+
// Infer moduleResolution from module if not explicitly set
116+
// TypeScript infers: "Node16" → "node16", "NodeNext" → "nodenext"
117+
let resolution = moduleRes?.toLowerCase();
118+
119+
if (!resolution && module) {
120+
const moduleLower = module.toLowerCase();
121+
if (moduleLower === 'node16' || moduleLower === 'nodenext') {
122+
resolution = moduleLower;
123+
Logger.verbose(
124+
`Inferred moduleResolution: ${resolution} from module: ${module}`
125+
);
126+
}
127+
}
128+
129+
if (resolution === 'bundler') {
130+
Logger.verbose(
131+
`Auto-detected moduleResolution: bundler → importExtension: 'none'`
132+
);
133+
return 'none';
134+
}
135+
136+
if (resolution === 'node16' || resolution === 'nodenext') {
137+
if (allowTsExtensions) {
138+
Logger.verbose(
139+
`Auto-detected moduleResolution: ${resolution} with allowImportingTsExtensions → importExtension: '.ts'`
140+
);
141+
return '.ts';
142+
}
143+
Logger.verbose(
144+
`Auto-detected moduleResolution: ${resolution} → importExtension: '.js'`
145+
);
146+
return '.js';
147+
}
148+
149+
return null;
150+
}
151+
152+
/**
153+
* Detects appropriate import extension based on project configuration.
154+
*
155+
* Detection priority:
156+
* 1. Bundler config files present → 'none'
157+
* 2. Bundler in dependencies → 'none'
158+
* 3. moduleResolution: 'bundler' → 'none'
159+
* 4. moduleResolution: 'node16'/'nodenext' (or inferred from module) + allowImportingTsExtensions → '.ts'
160+
* 5. moduleResolution: 'node16'/'nodenext' (or inferred from module) → '.js'
161+
* 6. Otherwise → null (use default)
162+
*
163+
* Note: If moduleResolution is not set but module is 'Node16' or 'NodeNext',
164+
* moduleResolution is inferred per TypeScript's behavior.
165+
*
166+
* @param projectDir - Directory to analyze (where package.json/tsconfig.json are)
167+
* @returns Detected import extension or null if no detection could be made
168+
*/
169+
export async function detectTypeScriptImportExtension(
170+
projectDir: string
171+
): Promise<ImportExtension | null> {
172+
try {
173+
// Step 1: Check for bundler config files
174+
const bundlerConfig = detectBundlerConfigFile(projectDir);
175+
if (bundlerConfig) {
176+
Logger.verbose(
177+
`Auto-detected bundler config: ${bundlerConfig} → importExtension: 'none'`
178+
);
179+
return 'none';
180+
}
181+
182+
// Step 2: Check package.json for bundler dependencies
183+
const packageJsonPath = path.join(projectDir, 'package.json');
184+
const packageJson = readJsonFile<PackageJson>(packageJsonPath);
185+
186+
if (packageJson) {
187+
const bundlerDep = detectBundlerDependency(packageJson);
188+
if (bundlerDep) {
189+
Logger.verbose(
190+
`Auto-detected bundler dependency: ${bundlerDep} → importExtension: 'none'`
191+
);
192+
return 'none';
193+
}
194+
}
195+
196+
// Step 3: Read tsconfig.json for moduleResolution (use JSONC parser)
197+
const tsconfigPath = path.join(projectDir, 'tsconfig.json');
198+
const tsconfig = readJsonFile<TsConfig>(tsconfigPath, true);
199+
200+
if (!tsconfig) {
201+
Logger.verbose(
202+
'Could not read tsconfig.json, skipping import extension detection'
203+
);
204+
return null;
205+
}
206+
207+
const options = tsconfig.compilerOptions;
208+
if (!options) {
209+
Logger.verbose('No compilerOptions in tsconfig.json');
210+
return null;
211+
}
212+
213+
// Step 4: Check moduleResolution (with inference from module if needed)
214+
return detectFromModuleResolution(
215+
options.moduleResolution,
216+
options.module,
217+
options.allowImportingTsExtensions
218+
);
219+
} catch (error) {
220+
Logger.verbose(`Project detection failed: ${error}`);
221+
return null;
222+
}
223+
}

0 commit comments

Comments
 (0)