Skip to content

Commit 720d27f

Browse files
committed
feat(): add default-export-loader util
1 parent 8205922 commit 720d27f

File tree

6 files changed

+176
-25
lines changed

6 files changed

+176
-25
lines changed

packages/angular-mcp-server/src/lib/tools/ds/component/utils/deprecated-css-helpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from 'fs';
22
import { resolveCrossPlatformPath } from '../../shared/utils/cross-platform-path.js';
3+
import { loadDefaultExport } from '@push-based/utils';
34

45
export interface DeprecatedCssComponent {
56
componentName: string;
@@ -31,9 +32,8 @@ export async function getDeprecatedCssClasses(
3132
throw new Error(`File not found at deprecatedCssClassesPath: ${absPath}`);
3233
}
3334

34-
const module = await import(absPath);
35-
36-
const dsComponents = module.default;
35+
const dsComponents =
36+
await loadDefaultExport<DeprecatedCssComponent[]>(absPath);
3737

3838
if (!Array.isArray(dsComponents)) {
3939
throw new Error('Invalid export: expected dsComponents to be an array');

packages/angular-mcp-server/src/lib/validation/ds-components-file-loader.validation.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
3-
import { pathToFileURL } from 'node:url';
43
import {
54
DsComponentsArraySchema,
65
DsComponentSchema,
76
} from './ds-components.schema.js';
87
import { z } from 'zod';
8+
import { loadDefaultExport } from '@push-based/utils';
99

1010
export type DsComponent = z.infer<typeof DsComponentSchema>;
1111
export type DsComponentsArray = z.infer<typeof DsComponentsArraySchema>;
@@ -65,10 +65,7 @@ export async function loadAndValidateDsComponentsFile(
6565
}
6666

6767
try {
68-
const fileUrl = pathToFileURL(absPath).toString();
69-
const module = await import(fileUrl);
70-
71-
const rawData = module.default;
68+
const rawData = await loadDefaultExport(absPath);
7269

7370
return validateDsComponentsArray(rawData);
7471
} catch (ctx) {

packages/angular-mcp-server/src/lib/validation/ds-components-file.validation.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from 'path';
2-
import { pathToFileURL } from 'url';
32
import { AngularMcpServerOptions } from './angular-mcp-server-options.schema.js';
43
import { DsComponentsArraySchema } from './ds-components.schema.js';
4+
import { loadDefaultExport } from '@push-based/utils';
55

66
export async function validateDeprecatedCssClassesFile(
77
config: AngularMcpServerOptions,
@@ -11,22 +11,7 @@ export async function validateDeprecatedCssClassesFile(
1111
config.ds.deprecatedCssClassesPath,
1212
);
1313

14-
let dsComponents;
15-
try {
16-
const fileUrl = pathToFileURL(deprecatedCssClassesAbsPath).toString();
17-
const module = await import(fileUrl);
18-
19-
dsComponents = module.default;
20-
} catch (ctx) {
21-
throw new Error(
22-
`Failed to load deprecated CSS classes configuration file: ${deprecatedCssClassesAbsPath}\n\n` +
23-
`Possible causes:\n` +
24-
`- File does not exist\n` +
25-
`- Invalid JavaScript syntax\n` +
26-
`- File permission issues\n\n` +
27-
`Original error: ${ctx}`,
28-
);
29-
}
14+
const dsComponents = await loadDefaultExport(deprecatedCssClassesAbsPath);
3015

3116
const validation = DsComponentsArraySchema.safeParse(dsComponents);
3217
if (!validation.success) {

packages/shared/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './lib/execute-process.js';
33
export * from './lib/logging.js';
44
export * from './lib/file/find-in-file.js';
55
export * from './lib/file/file.resolver.js';
6+
export * from './lib/file/default-export-loader.js';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2+
import { writeFileSync, rmSync, mkdirSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { loadDefaultExport } from './default-export-loader.js';
6+
7+
describe('loadDefaultExport', () => {
8+
let testDir: string;
9+
10+
beforeEach(() => {
11+
testDir = join(
12+
tmpdir(),
13+
`test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
14+
);
15+
mkdirSync(testDir, { recursive: true });
16+
});
17+
18+
afterEach(() => {
19+
rmSync(testDir, { recursive: true, force: true });
20+
});
21+
22+
const createFile = (name: string, content: string) => {
23+
const path = join(testDir, name);
24+
writeFileSync(path, content, 'utf-8');
25+
return path;
26+
};
27+
28+
describe('Success Cases', () => {
29+
it.each([
30+
{
31+
type: 'array',
32+
content: '[{name: "test"}]',
33+
expected: [{ name: 'test' }],
34+
},
35+
{
36+
type: 'object',
37+
content: '{version: "1.0"}',
38+
expected: { version: '1.0' },
39+
},
40+
{ type: 'string', content: '"test"', expected: 'test' },
41+
{ type: 'null', content: 'null', expected: null },
42+
{ type: 'boolean', content: 'false', expected: false },
43+
{ type: 'undefined', content: 'undefined', expected: undefined },
44+
])('should load $type default export', async ({ content, expected }) => {
45+
const path = createFile('test.mjs', `export default ${content};`);
46+
expect(await loadDefaultExport(path)).toEqual(expected);
47+
});
48+
});
49+
50+
describe('Error Cases - No Default Export', () => {
51+
it.each([
52+
{
53+
desc: 'named exports only',
54+
content: 'export const a = 1; export const b = 2;',
55+
exports: 'a, b',
56+
},
57+
{ desc: 'empty module', content: '', exports: 'none' },
58+
{ desc: 'comments only', content: '// comment', exports: 'none' },
59+
{
60+
desc: 'function exports',
61+
content: 'export function fn() {}',
62+
exports: 'fn',
63+
},
64+
])('should throw error for $desc', async ({ content, exports }) => {
65+
const path = createFile('test.mjs', content);
66+
await expect(loadDefaultExport(path)).rejects.toThrow(
67+
`No default export found in module. Expected ES Module format:\nexport default [...]\n\nAvailable exports: ${exports}`,
68+
);
69+
});
70+
});
71+
72+
describe('Error Cases - File System', () => {
73+
it('should throw error when file does not exist', async () => {
74+
const path = join(testDir, 'missing.mjs');
75+
await expect(loadDefaultExport(path)).rejects.toThrow(
76+
`Failed to load module from ${path}`,
77+
);
78+
});
79+
80+
it('should throw error when file has syntax errors', async () => {
81+
const path = createFile(
82+
'syntax.mjs',
83+
'export default { invalid: syntax }',
84+
);
85+
await expect(loadDefaultExport(path)).rejects.toThrow(
86+
`Failed to load module from ${path}`,
87+
);
88+
});
89+
});
90+
91+
describe('Edge Cases', () => {
92+
it('should work with TypeScript generics', async () => {
93+
interface Config {
94+
name: string;
95+
}
96+
const path = createFile('typed.mjs', 'export default [{name: "test"}];');
97+
const result = await loadDefaultExport<Config[]>(path);
98+
expect(result).toEqual([{ name: 'test' }]);
99+
});
100+
101+
it('should handle mixed exports (prefers default)', async () => {
102+
const path = createFile(
103+
'mixed.mjs',
104+
'export const named = "n"; export default "d";',
105+
);
106+
expect(await loadDefaultExport<string>(path)).toBe('d');
107+
});
108+
109+
it('should handle complex nested structures', async () => {
110+
const path = createFile(
111+
'complex.mjs',
112+
`
113+
export default {
114+
data: [{ name: 'test', meta: { date: new Date('2024-01-01') } }],
115+
version: '1.0'
116+
};
117+
`,
118+
);
119+
const result = await loadDefaultExport(path);
120+
expect(result).toMatchObject({
121+
data: [{ name: 'test', meta: { date: expect.any(Date) } }],
122+
version: '1.0',
123+
});
124+
});
125+
});
126+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { pathToFileURL } from 'node:url';
2+
3+
/**
4+
* Dynamically imports an ES Module and extracts the default export.
5+
*
6+
* @param filePath - Absolute path to the ES module file to import
7+
* @returns The default export from the module
8+
* @throws Error if the module cannot be loaded or has no default export
9+
*
10+
* @example
11+
* ```typescript
12+
* const data = await loadDefaultExport('/path/to/config.js');
13+
* ```
14+
*/
15+
export async function loadDefaultExport<T = unknown>(
16+
filePath: string,
17+
): Promise<T> {
18+
try {
19+
const fileUrl = pathToFileURL(filePath).toString();
20+
const module = await import(fileUrl);
21+
22+
if (!('default' in module)) {
23+
throw new Error(
24+
`No default export found in module. Expected ES Module format:\n` +
25+
`export default [...]\n\n` +
26+
`Available exports: ${Object.keys(module).join(', ') || 'none'}`,
27+
);
28+
}
29+
30+
return module.default;
31+
} catch (ctx) {
32+
if (
33+
ctx instanceof Error &&
34+
ctx.message.includes('No default export found')
35+
) {
36+
throw ctx;
37+
}
38+
throw new Error(
39+
`Failed to load module from ${filePath}: ${ctx instanceof Error ? ctx.message : String(ctx)}`,
40+
);
41+
}
42+
}

0 commit comments

Comments
 (0)