Skip to content

Commit aee50d9

Browse files
committed
refactor: use jiti to load module
1 parent c9cb895 commit aee50d9

File tree

9 files changed

+348
-16
lines changed

9 files changed

+348
-16
lines changed

nx.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,15 @@
127127
"executor": "nx:run-commands",
128128
"dependsOn": ["code-pushup-*"],
129129
"options": {
130-
"command": "npx jiti-tsc packages/cli/src/index.ts",
130+
"command": "node packages/cli/src/index.ts",
131131
"args": [
132132
"--config={projectRoot}/code-pushup.config.ts",
133133
"--cache.read",
134134
"--persist.outputDir=.code-pushup/{projectName}"
135135
],
136136
"env": {
137-
"JITI_TS_CONFIG_PATH": "tsconfig.base.json"
137+
"NODE_OPTIONS": "--import tsx",
138+
"TSX_TSCONFIG_PATH": "tsconfig.base.json"
138139
}
139140
}
140141
},

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"husky": "^8.0.0",
104104
"inquirer": "^9.3.7",
105105
"jest-extended": "^6.0.0",
106-
"jiti": "2.4.2",
106+
"jiti": "^2.4.2",
107107
"jsdom": "~24.0.0",
108108
"jsonc-eslint-parser": "^2.4.0",
109109
"knip": "^5.33.3",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { name } from '@utils';
2+
3+
export default `valid-ts-default-export-${name}`;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"compilerOptions": {
3+
"paths": {
4+
"@utils/*": ["./utils.ts"]
5+
}
6+
},
7+
"include": ["*.ts"]
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const name = 'utils-export';

packages/utils/src/lib/import-module.int.test.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'node:path';
2-
import { describe, expect, it } from 'vitest';
3-
import { importModule } from './import-module.js';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { deriveTsConfig, importModule } from './import-module.js';
44

55
describe('importModule', () => {
66
const mockDir = path.join(
@@ -45,6 +45,25 @@ describe('importModule', () => {
4545
).resolves.toBe('valid-ts-default-export');
4646
});
4747

48+
it('imports module with default tsconfig when tsconfig undefined', async () => {
49+
vi.clearAllMocks();
50+
await expect(
51+
importModule({
52+
filepath: path.join(mockDir, 'valid-ts-default-export.ts'),
53+
}),
54+
).resolves.toBe('valid-ts-default-export');
55+
});
56+
57+
it('imports module with custom tsconfig', async () => {
58+
vi.clearAllMocks();
59+
await expect(
60+
importModule({
61+
filepath: path.join(mockDir, 'tsconfig-setup', 'import-alias.ts'),
62+
tsconfig: path.join(mockDir, 'tsconfig-setup', 'tsconfig.json'),
63+
}),
64+
).resolves.toBe('valid-ts-default-export-utils-export');
65+
});
66+
4867
it('should throw if the file does not exist', async () => {
4968
await expect(
5069
importModule({ filepath: 'path/to/non-existent-export.mjs' }),
@@ -57,11 +76,41 @@ describe('importModule', () => {
5776
);
5877
});
5978

60-
it('should throw if file is not valid JS', async () => {
79+
it('should load valid JSON', async () => {
6180
await expect(
6281
importModule({ filepath: path.join(mockDir, 'invalid-js-file.json') }),
63-
).rejects.toThrow(
64-
`${path.join(mockDir, 'invalid-js-file.json')} is not a valid JS file`,
65-
);
82+
).resolves.toStrictEqual({ key: 'value' });
83+
});
84+
});
85+
86+
describe('deriveTsConfig', () => {
87+
const mockDir = path.join(
88+
process.cwd(),
89+
'packages',
90+
'utils',
91+
'mocks',
92+
'fixtures',
93+
);
94+
95+
it('should load a valid tsconfig.json file', async () => {
96+
const configPath = path.join(mockDir, 'tsconfig-setup', 'tsconfig.json');
97+
98+
await expect(deriveTsConfig(configPath)).resolves.toStrictEqual({
99+
configFilePath: expect.any(String),
100+
paths: {
101+
'@utils/*': ['./utils.ts'],
102+
},
103+
pathsBasePath: expect.any(String),
104+
});
105+
});
106+
107+
it('should throw if the path is empty', async () => {
108+
await expect(deriveTsConfig('')).rejects.toThrow(/Tsconfig file not found/);
109+
});
110+
111+
it('should throw if the file does not exist', async () => {
112+
await expect(
113+
deriveTsConfig(path.join('non-existent', 'tsconfig.json')),
114+
).rejects.toThrow(/Tsconfig file not found/);
66115
});
67116
});
Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { type Options, bundleRequire } from 'bundle-require';
1+
import { type JitiOptions, createJiti as createJitiSource } from 'jiti';
22
import { stat } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import type { CompilerOptions } from 'typescript';
5+
import { fileExists } from './file-system';
6+
import { loadTargetConfig } from './load-ts-config';
37
import { settlePromise } from './promises.js';
48

5-
export async function importModule<T = unknown>(options: Options): Promise<T> {
9+
export async function importModule<T = unknown>(
10+
options: JitiOptions & { filepath: string; tsconfig?: string },
11+
): Promise<T> {
12+
const { filepath, tsconfig, ...jitiOptions } = options;
13+
614
const resolvedStats = await settlePromise(stat(options.filepath));
715
if (resolvedStats.status === 'rejected') {
816
throw new Error(`File '${options.filepath}' does not exist`);
@@ -11,10 +19,154 @@ export async function importModule<T = unknown>(options: Options): Promise<T> {
1119
throw new Error(`Expected '${options.filepath}' to be a file`);
1220
}
1321

14-
const { mod } = await bundleRequire<object>(options);
22+
const jitiInstance = await createTsJiti(options.filepath, {
23+
...jitiOptions,
24+
tsconfigPath: options.tsconfig,
25+
});
26+
return (await jitiInstance.import(filepath, { default: true })) as T;
27+
}
28+
29+
/**
30+
* Converts TypeScript paths configuration to jiti alias format
31+
* @param paths TypeScript paths object from compiler options
32+
* @param baseUrl Base URL for resolving relative paths
33+
* @returns Jiti alias object with absolute paths
34+
*/
35+
export function mapTsPathsToJitiAlias(
36+
paths: Record<string, string[]>,
37+
baseUrl: string,
38+
): Record<string, string> {
39+
return Object.entries(paths).reduce(
40+
(aliases, [pathPattern, pathMappings]) => {
41+
if (!Array.isArray(pathMappings) || pathMappings.length === 0) {
42+
return aliases;
43+
}
44+
// Jiti does not support overloads (multiple mappings for the same path pattern)
45+
if (pathMappings.length > 1) {
46+
throw new Error(
47+
`TypeScript path overloads are not supported by jiti. Path pattern '${pathPattern}' has ${pathMappings.length} mappings: ${pathMappings.join(', ')}. Jiti only supports a single alias mapping per pattern.`,
48+
);
49+
}
50+
const aliasKey = pathPattern.replace(/\/\*$/, '');
51+
const aliasValue = (pathMappings.at(0) as string).replace(/\/\*$/, '');
52+
return {
53+
...aliases,
54+
[aliasKey]: path.isAbsolute(aliasValue)
55+
? aliasValue
56+
: path.resolve(baseUrl, aliasValue),
57+
};
58+
},
59+
{} satisfies Record<string, string>,
60+
);
61+
}
62+
63+
/**
64+
* Maps TypeScript JSX emit mode to Jiti JSX boolean option
65+
* @param tsJsxMode TypeScript JsxEmit enum value (0-5)
66+
* @returns true if JSX processing should be enabled, false otherwise
67+
*/
68+
export const mapTsJsxToJitiJsx = (tsJsxMode: number): boolean =>
69+
tsJsxMode !== 0;
70+
71+
/**
72+
* Possible TS to jiti options mapping
73+
* | Jiti Option | Jiti Type | TS Option | TS Type | Description |
74+
* |-------------------|-------------------------|-----------------------|--------------------------|-------------|
75+
* | alias | Record<string, string> | paths | Record<string, string[]> | Module path aliases for module resolution. |
76+
* | interopDefault | boolean | esModuleInterop | boolean | Enable default import interop. |
77+
* | sourceMaps | boolean | sourceMap | boolean | Enable sourcemap generation. |
78+
* | jsx | boolean | jsx | JsxEmit (0-5) | TS JsxEmit enum (0-5) => boolean JSX processing. |
79+
*/
80+
export type MappableJitiOptions = Partial<
81+
Pick<JitiOptions, 'alias' | 'interopDefault' | 'sourceMaps' | 'jsx'>
82+
>;
83+
84+
/**
85+
* Parse TypeScript compiler options to mappable jiti options
86+
* @param compilerOptions TypeScript compiler options
87+
* @param tsconfigDir Directory of the tsconfig file (for resolving relative baseUrl)
88+
* @returns Mappable jiti options
89+
*/
90+
export function parseTsConfigToJitiConfig(
91+
compilerOptions: CompilerOptions,
92+
tsconfigDir?: string,
93+
): MappableJitiOptions {
94+
const paths = compilerOptions.paths || {};
95+
const baseUrl = compilerOptions.baseUrl
96+
? path.isAbsolute(compilerOptions.baseUrl)
97+
? compilerOptions.baseUrl
98+
: tsconfigDir
99+
? path.resolve(tsconfigDir, compilerOptions.baseUrl)
100+
: path.resolve(process.cwd(), compilerOptions.baseUrl)
101+
: tsconfigDir || process.cwd();
15102

16-
if (typeof mod === 'object' && 'default' in mod) {
17-
return mod.default as T;
103+
return {
104+
...(Object.keys(paths).length > 0
105+
? {
106+
alias: mapTsPathsToJitiAlias(paths, baseUrl),
107+
}
108+
: {}),
109+
...(compilerOptions.esModuleInterop == null
110+
? {}
111+
: { interopDefault: compilerOptions.esModuleInterop }),
112+
...(compilerOptions.sourceMap == null
113+
? {}
114+
: { sourceMaps: compilerOptions.sourceMap }),
115+
...(compilerOptions.jsx == null
116+
? {}
117+
: { jsx: mapTsJsxToJitiJsx(compilerOptions.jsx) }),
118+
};
119+
}
120+
121+
/**
122+
* Create a jiti instance with options derived from tsconfig.
123+
* Used instead of direct jiti.createJiti to allow tsconfig integration.
124+
* @param filepath
125+
* @param options
126+
* @param jiti
127+
*/
128+
export async function createTsJiti(
129+
filepath: string,
130+
options: JitiOptions & { tsconfigPath?: string },
131+
createJiti: (typeof import('jiti'))['createJiti'] = createJitiSource,
132+
) {
133+
const { tsconfigPath, ...jitiOptions } = options;
134+
const fallbackTsconfigPath = path.resolve('./tsconfig.json');
135+
const tsDerivedJitiOptions: MappableJitiOptions = tsconfigPath
136+
? await jitiOptionsFromTsConfig(tsconfigPath)
137+
: (await fileExists(fallbackTsconfigPath))
138+
? await jitiOptionsFromTsConfig(tsconfigPath)
139+
: {};
140+
return createJiti(filepath, { ...jitiOptions, ...tsDerivedJitiOptions });
141+
}
142+
143+
/**
144+
* Read tsconfig file and parse options to jiti options
145+
* @param tsconfigPath
146+
*/
147+
export async function jitiOptionsFromTsConfig(
148+
tsconfigPath: string,
149+
): Promise<MappableJitiOptions> {
150+
const compilerOptions = await deriveTsConfig(tsconfigPath);
151+
const tsconfigDir = path.dirname(tsconfigPath);
152+
return parseTsConfigToJitiConfig(compilerOptions, tsconfigDir);
153+
}
154+
155+
/**
156+
* Read tsconfig file by path and return the parsed options as JSON object
157+
* @param tsconfigPath
158+
*/
159+
export async function deriveTsConfig(
160+
tsconfigPath: string,
161+
): Promise<CompilerOptions> {
162+
// check if tsconfig file exists
163+
const exists = await fileExists(tsconfigPath);
164+
if (!exists) {
165+
throw new Error(
166+
`Tsconfig file not found at path: ${tsconfigPath.replace(/\\/g, '/')}`,
167+
);
18168
}
19-
return mod as T;
169+
170+
const { options } = loadTargetConfig(tsconfigPath);
171+
return options;
20172
}

0 commit comments

Comments
 (0)