Skip to content
This repository was archived by the owner on Mar 11, 2026. It is now read-only.

Commit 5baaa1c

Browse files
feat: support commonjs-like directory imports (#6)
1 parent f3703d9 commit 5baaa1c

23 files changed

Lines changed: 276 additions & 80 deletions

README.md

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ npx codemod@latest correct-ts-specifiers
3939
* `.js``.d.cts`, `.d.mts`, or `.d.ts`
4040
* Package.json subimports
4141
* tsconfig paths (requires a loader)
42+
* Commonjs-like directory specifiers
4243

4344
Before:
4445

@@ -48,9 +49,10 @@ import { URL } from 'node:url';
4849
import { bar } from '@dep/bar';
4950
import { foo } from 'foo';
5051

52+
import { Bird } from './Bird'; // a directory
5153
import { Cat } from './Cat.ts';
52-
import { Dog } from '…/Dog/index.mjs'; // tsconfig paths
53-
import { baseUrl } from '#config.js'; // package.json imports
54+
import { Dog } from '…/Dog/index.mjs'; // tsconfig paths
55+
import { baseUrl } from '#config.js'; // package.json imports
5456

5557
export { Zed } from './zed';
5658

@@ -60,6 +62,7 @@ export const makeLink = (path: URL) => (new URL(path, baseUrl)).href;
6062

6163
const nil = await import('./nil.js');
6264

65+
const bird = new Bird('Tweety');
6366
const cat = new Cat('Milo');
6467
const dog = new Dog('Otis');
6568
```
@@ -72,9 +75,10 @@ import { URL } from 'node:url';
7275
import { bar } from '@dep/bar';
7376
import { foo } from 'foo';
7477

78+
import { Bird } from './Bird/index.ts';
7579
import { Cat } from './Cat.ts';
76-
import { Dog } from '…/Dog/index.mts'; // tsconfig paths
77-
import { baseUrl } from '#config.js'; // package.json imports
80+
import { Dog } from '…/Dog/index.mts'; // tsconfig paths
81+
import { baseUrl } from '#config.js'; // package.json imports
7882

7983
export type { Zed } from './zed.d.ts';
8084

@@ -84,16 +88,7 @@ export const makeLink = (path: URL) => (new URL(path, baseUrl)).href;
8488

8589
const nil = await import('./nil.ts');
8690

91+
const bird = new Bird('Tweety');
8792
const cat = new Cat('Milo');
8893
const dog = new Dog('Otis');
8994
```
90-
91-
## Unsupported cases
92-
93-
* Directory / commonjs-like specifiers¹
94-
95-
```ts
96-
import foo from '..'; // where '..' → '../index.ts' (or similar)
97-
```
98-
99-
¹ Support may be added in a future release

src/exts.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
/**
2+
* A map of JavaScript file extensions to the corresponding TypeScript file extension.
3+
*/
14
export const jsToTSExts = {
25
'.cjs': '.cts',
36
'.mjs': '.mts',
47
'.js': '.ts',
58
'.jsx': '.tsx',
69
} as const;
10+
/**
11+
* File extensions that potentially need to be corrected
12+
*/
713
export const suspectExts = {
814
'': '.js',
915
...jsToTSExts,
@@ -14,9 +20,21 @@ export const jsExts = Object.keys(jsToTSExts) as Array<JSExt>;
1420
export const tsExts = Object.values(jsToTSExts);
1521
export type TSExt = typeof tsExts[number];
1622

23+
/**
24+
* File extensions for TypeScript type declaration files.
25+
*/
1726
export const dExts = [
1827
'.d.cts',
1928
'.d.ts',
2029
'.d.mts',
2130
] as const;
2231
export type DExt = typeof dExts[number];
32+
33+
/**
34+
* A master list of file extensions to check.
35+
*/
36+
export const extSets = new Set([
37+
jsExts,
38+
tsExts,
39+
dExts,
40+
]);

src/fexists.test.ts

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import assert from 'node:assert/strict';
22
import {
33
type Mock,
4-
after,
54
afterEach,
65
before,
76
describe,
@@ -13,7 +12,8 @@ import { fileURLToPath } from 'node:url';
1312

1413
type FSAccess = typeof import('node:fs/promises').access;
1514
type FExists = typeof import('./fexists.ts').fexists;
16-
type MockModuleContext = ReturnType<typeof mock.module>;
15+
type ResolveSpecifier = typeof import('./resolve-specifier.ts').resolveSpecifier;
16+
// type MockModuleContext = ReturnType<typeof mock.module>;
1717

1818
const RESOLVED_SPECIFIER_ERR = 'Resolved specifier did not match expected';
1919

@@ -22,21 +22,26 @@ describe('fexists', () => {
2222
const constants = { F_OK: null };
2323

2424
let mock__access: Mock<FSAccess>['mock'];
25-
let mock__fs: MockModuleContext;
25+
let mock__resolveSpecifier: Mock<ResolveSpecifier>['mock'];
2626

2727
before(() => {
2828
const access = mock.fn<FSAccess>();
2929
({ mock: mock__access } = access);
30-
mock__fs = mock.module('node:fs/promises', {
30+
mock.module('node:fs/promises', {
3131
namedExports: {
3232
access,
3333
constants,
3434
},
3535
});
36-
});
3736

38-
after(() => {
39-
mock__fs.restore();
37+
const resolveSpecifier = mock.fn<ResolveSpecifier>();
38+
({ mock: mock__resolveSpecifier } = resolveSpecifier);
39+
mock.module('./resolve-specifier.ts', {
40+
namedExports: {
41+
resolveSpecifier,
42+
},
43+
});
44+
mock__resolveSpecifier.mockImplementation(function MOCK__resolveSpecifier(pp, specifier) { return specifier });
4045
});
4146

4247
describe('when the file exists', () => {
@@ -52,35 +57,34 @@ describe('fexists', () => {
5257
mock__access.resetCalls();
5358
});
5459

55-
after(() => {
56-
mock__fs.restore();
57-
});
58-
5960
it('should return `true` for a bare specifier', async () => {
61+
const specifier = 'foo';
6062
const parentUrl = fileURLToPath(import.meta.resolve('./fixtures/e2e/test.js'));
6163

62-
assert.equal(await fexists(parentUrl, 'foo'), true);
64+
assert.equal(await fexists(parentUrl, specifier), true);
6365
assert.equal(
6466
mock__access.calls[0].arguments[0],
65-
fileURLToPath(import.meta.resolve('./fixtures/e2e/node_modules/foo/foo.js')),
67+
specifier,
6668
RESOLVED_SPECIFIER_ERR,
6769
);
6870
});
6971

7072
it('should return `true` for a relative specifier', async () => {
71-
assert.equal(await fexists(parentPath, 'exists.js'), true);
73+
const specifier = 'exists.js';
74+
assert.equal(await fexists(parentPath, specifier), true);
7275
assert.equal(
7376
mock__access.calls[0].arguments[0],
74-
'/tmp/exists.js',
77+
specifier,
7578
RESOLVED_SPECIFIER_ERR,
7679
);
7780
});
7881

7982
it('should return `true` for specifier with a query parameter', async () => {
80-
assert.equal(await fexists(parentPath, 'exists.js?v=1'), true);
83+
const specifier = 'exists.js?v=1';
84+
assert.equal(await fexists(parentPath, specifier), true);
8185
assert.equal(
8286
mock__access.calls[0].arguments[0],
83-
'/tmp/exists.js',
87+
specifier,
8488
RESOLVED_SPECIFIER_ERR,
8589
);
8690
});
@@ -119,42 +123,42 @@ describe('fexists', () => {
119123
mock__access.resetCalls();
120124
});
121125

122-
after(() => {
123-
mock__fs.restore();
124-
});
125-
126126
it('should return `false` for a relative specifier', async () => {
127-
assert.equal(await fexists(parentPath, 'noexists.js'), false);
127+
const specifier = 'noexists.js';
128+
assert.equal(await fexists(parentPath, specifier), false);
128129
assert.equal(
129130
mock__access.calls[0].arguments[0],
130-
'/tmp/noexists.js',
131+
specifier,
131132
RESOLVED_SPECIFIER_ERR,
132133
);
133134
});
134135

135136
it('should return `false` for a relative specifier', async () => {
136-
assert.equal(await fexists(parentPath, 'noexists.js?v=1'), false);
137+
const specifier = 'noexists.js?v=1';
138+
assert.equal(await fexists(parentPath, specifier), false);
137139
assert.equal(
138140
mock__access.calls[0].arguments[0],
139-
'/tmp/noexists.js',
141+
specifier,
140142
RESOLVED_SPECIFIER_ERR,
141143
);
142144
});
143145

144146
it('should return `false` for an absolute specifier', async () => {
145-
assert.equal(await fexists(parentPath, '/tmp/foo/noexists.js'), false);
147+
const specifier = '/tmp/foo/noexists.js';
148+
assert.equal(await fexists(parentPath, specifier), false);
146149
assert.equal(
147150
mock__access.calls[0].arguments[0],
148-
'/tmp/foo/noexists.js',
151+
specifier,
149152
RESOLVED_SPECIFIER_ERR,
150153
);
151154
});
152155

153156
it('should return `false` for a URL specifier', async () => {
154-
assert.equal(await fexists(parentPath, 'file://localhost/foo/noexists.js'), false);
157+
const specifier = 'file://localhost/foo/noexists.js';
158+
assert.equal(await fexists(parentPath, specifier), false);
155159
assert.equal(
156160
mock__access.calls[0].arguments[0],
157-
'file://localhost/foo/noexists.js',
161+
specifier,
158162
RESOLVED_SPECIFIER_ERR,
159163
);
160164
});

src/fexists.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
1-
import { dirname } from 'node:path';
21
import { access, constants } from 'node:fs/promises';
3-
import { fileURLToPath, pathToFileURL } from 'node:url';
42

53
import type { FSAbsolutePath, Specifier } from './index.d.ts';
4+
import { resolveSpecifier } from './resolve-specifier.ts';
65

76

87
export function fexists(
98
parentPath: FSAbsolutePath,
109
specifier: Specifier,
1110
) {
12-
const parentUrl = `${pathToFileURL(dirname(parentPath)).href}/`;
13-
14-
const resolvedSpecifier: FSAbsolutePath = URL.canParse(specifier)
15-
? specifier
16-
// import.meta.resolve gives access to node's resolution algorithm, which is necessary to handle
17-
// a myriad of non-obvious routes, like pJson subimports and the result of any hooks that may be
18-
// helping, such as ones facilitating tsconfig's "paths"
19-
: fileURLToPath(import.meta.resolve(specifier, parentUrl));
11+
const resolvedSpecifier = resolveSpecifier(parentPath, specifier);
2012

2113
return access(
2214
resolvedSpecifier,

src/fixtures/dir/cjs/index.cjs

Whitespace-only changes.

src/fixtures/dir/cts/index.cts

Whitespace-only changes.

src/fixtures/dir/js/index.js

Whitespace-only changes.

src/fixtures/dir/jsx/index.jsx

Whitespace-only changes.

src/fixtures/dir/mjs/index.mjs

Whitespace-only changes.

src/fixtures/dir/mts/index.mts

Whitespace-only changes.

0 commit comments

Comments
 (0)