Skip to content

Commit 7b9d1e7

Browse files
committed
feat(utils): fillModules
Signed-off-by: Lexus Drumgold <unicornware@flexdevelopment.llc>
1 parent ffb0481 commit 7b9d1e7

File tree

4 files changed

+211
-0
lines changed

4 files changed

+211
-0
lines changed

docs/.vitepress/theme/comments/link-replacements.json

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"{@linkcode compareSubpaths}": "/api/#comparesubpaths",
5050
"{@linkcode detectSyntax}": "/api/#detectsyntax",
5151
"{@linkcode extractStatements}": "/api/#extractstatements",
52+
"{@linkcode fillModules}": "/api/#fillmodules",
5253
"{@linkcode findDynamicImports}": "/api/#finddynamicimports",
5354
"{@linkcode findExports}": "/api/#findexports",
5455
"{@linkcode findRequires}": "/api/#findrequires",
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @file Unit Tests - fillModules
3+
* @module mlly/utils/tests/unit/fillModules
4+
*/
5+
6+
import isFunction from '#src/internal/is-function'
7+
import type { ChangeExtFn, ModuleId } from '#src/types'
8+
import { ErrorCode, type NodeError } from '@flex-development/errnode'
9+
import pathe from '@flex-development/pathe'
10+
import type { Nilable } from '@flex-development/tutils'
11+
import { pathToFileURL } from 'node:url'
12+
import { dedent } from 'ts-dedent'
13+
import extractStatements from '../extract-statements'
14+
import testSubject from '../fill-modules'
15+
import isRelativeSpecifier from '../is-relative-specifier'
16+
17+
describe('unit:utils/fillModules', () => {
18+
let code: string
19+
let parent: ModuleId
20+
21+
beforeAll(() => {
22+
code = dedent`
23+
import { SpecifierKind } from '#src/enums'
24+
import type { FillModuleOptions } from '#src/interfaces'
25+
import isFunction from '#src/internal/is-function'
26+
import { ERR_UNKNOWN_FILE_EXTENSION } from '@flex-development/errnode'
27+
import pathe, { type Ext } from '@flex-development/pathe'
28+
import type { EmptyString } from '@flex-development/tutils'
29+
import CONDITIONS from '${pathToFileURL('src/utils/conditions.ts').href}'
30+
import assert from 'node:assert'
31+
import type { URL } from 'node:url'
32+
import extractStatements from './extract-statements'
33+
import isAbsoluteSpecifier from './is-absolute-specifier'
34+
import isBareSpecifier from './is-bare-specifier'
35+
import resolveModule from './resolve-module'
36+
import toBareSpecifier from './to-bare-specifier'
37+
import toRelativeSpecifier from './to-relative-specifier'
38+
39+
await import(foo)
40+
41+
export const hello = 'world'
42+
`
43+
parent = pathToFileURL('src/utils/fill-modules.ts')
44+
})
45+
46+
it('should return code with module specifiers fully specified', async () => {
47+
for (const ext of ['.mjs', 'mjs']) {
48+
const expected = extractStatements(code)
49+
.map(({ specifier }) => {
50+
return specifier
51+
? isRelativeSpecifier(specifier)
52+
? specifier + pathe.formatExt(ext)
53+
: specifier
54+
: ''
55+
})
56+
.filter(specifier => specifier.length > 0)
57+
const result = extractStatements(await testSubject(code, { ext, parent }))
58+
.map(s => s.specifier)
59+
.filter(Boolean)
60+
61+
expect(result).to.deep.equal(expected)
62+
}
63+
})
64+
65+
it('should throw if new file extension is empty', async () => {
66+
// Arrange
67+
const cases: (ChangeExtFn | Nilable<string>)[] = [, null, () => ' ', '']
68+
const error_code: ErrorCode = ErrorCode.ERR_UNKNOWN_FILE_EXTENSION
69+
70+
// Act + Expect
71+
for (const ext of cases) {
72+
const ext_regex: RegExp = new RegExp(`'${isFunction(ext) ? ' ' : ext}'`)
73+
let error: NodeError<TypeError>
74+
75+
try {
76+
await testSubject(code, {
77+
ext: ext as ChangeExtFn<string> | string,
78+
parent
79+
})
80+
} catch (e: unknown) {
81+
error = e as typeof error
82+
}
83+
84+
expect(error!).to.not.be.undefined
85+
expect(error!).to.have.property('code').equal(error_code)
86+
expect(error!).to.have.property('message').match(ext_regex)
87+
}
88+
})
89+
})

src/utils/fill-modules.ts

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @file fillModules
3+
* @module mlly/utils/fillModules
4+
*/
5+
6+
import { SpecifierKind } from '#src/enums'
7+
import type { FillModuleOptions } from '#src/interfaces'
8+
import isFunction from '#src/internal/is-function'
9+
import { ERR_UNKNOWN_FILE_EXTENSION } from '@flex-development/errnode'
10+
import pathe, { type Ext } from '@flex-development/pathe'
11+
import type { EmptyString } from '@flex-development/tutils'
12+
import type { URL } from 'node:url'
13+
import CONDITIONS from './conditions'
14+
import extractStatements from './extract-statements'
15+
import isAbsoluteSpecifier from './is-absolute-specifier'
16+
import isBareSpecifier from './is-bare-specifier'
17+
import resolveModule from './resolve-module'
18+
import toBareSpecifier from './to-bare-specifier'
19+
import toRelativeSpecifier from './to-relative-specifier'
20+
21+
/**
22+
* Ensures all absolute and relative module specifiers in the given piece of
23+
* source `code` are fully specified.
24+
*
25+
* Ignores specifiers that already have file extensions.
26+
*
27+
* @see {@linkcode FillModuleOptions}
28+
* @see https://nodejs.org/api/esm.html#mandatory-file-extensions
29+
* @see https://nodejs.org/api/esm.html#terminology
30+
*
31+
* @async
32+
*
33+
* @param {string} code - Code to evaluate
34+
* @param {FillModuleOptions} options - Module fill options
35+
* @return {Promise<string>} `code` with fully specified module specifiers
36+
*/
37+
const fillModules = async (
38+
code: string,
39+
options: FillModuleOptions
40+
): Promise<string> => {
41+
const { conditions = CONDITIONS, ext, parent = import.meta.url } = options
42+
43+
// ensure specifiers have file extensions
44+
for (const statement of extractStatements(code)) {
45+
// do nothing if statement does not have specifier
46+
if (!statement.specifier) continue
47+
48+
// ignore statements with dynamic specifiers
49+
if (statement.specifier_kind === SpecifierKind.DYNAMIC) continue
50+
51+
/**
52+
* Resolved module URL.
53+
*
54+
* @const {URL} url
55+
*/
56+
const url: URL = await resolveModule(statement.specifier, {
57+
...options,
58+
/**
59+
* Returns a replacement file extension for the given module `specifier`
60+
* **if it is non-bare and does not already have an extension**.
61+
*
62+
* Throws [`ERR_UNKNOWN_FILE_EXTENSION`][1] if the replacement extension
63+
* is `null`, `undefined`, an empty string, or a dot character (`'.'`).
64+
*
65+
* [1]: https://nodejs.org/api/errors.html#err_unknown_file_extension
66+
*
67+
* @async
68+
*
69+
* @param {string} specifier - Module specifier
70+
* @param {URL} url - Resolved module URL
71+
* @return {Promise<string | undefined>} New file extension or `undefined`
72+
*/
73+
async ext(specifier: string, url: URL): Promise<string | undefined> {
74+
/**
75+
* Current file extension of {@linkcode specifier}.
76+
*
77+
* @const {EmptyString | Ext} extname
78+
*/
79+
const extname: EmptyString | Ext = pathe.extname(specifier)
80+
81+
// skip replacement for bare and already fully specified specifiers
82+
if (isBareSpecifier(specifier) || extname.length > 1) return undefined
83+
84+
/**
85+
* Replacement file extension.
86+
*
87+
* @var {string} rext
88+
*/
89+
const rext: string = isFunction(ext) ? await ext(specifier, url) : ext
90+
91+
// ensure replacement extension is non-empty and non-dot ('.')
92+
if (!(rext && rext.trim().length > (rext.startsWith('.') ? 1 : 0))) {
93+
throw new ERR_UNKNOWN_FILE_EXTENSION(rext, specifier)
94+
}
95+
96+
return rext
97+
}
98+
})
99+
100+
// replace original module specifier
101+
code = code.replace(
102+
statement.code,
103+
statement.code.replace(
104+
statement.specifier,
105+
// convert module url back to absolute, bare, or relative specifier
106+
statement.specifier.startsWith('#')
107+
? statement.specifier
108+
: isAbsoluteSpecifier(statement.specifier)
109+
? url.href
110+
: isBareSpecifier(statement.specifier)
111+
? toBareSpecifier(url, parent, conditions)
112+
: toRelativeSpecifier(url, parent)
113+
)
114+
)
115+
}
116+
117+
return code
118+
}
119+
120+
export default fillModules

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { default as CONDITIONS } from './conditions'
88
export { default as detectSyntax } from './detect-syntax'
99
export { default as EXTENSION_FORMAT_MAP } from './extension-format-map'
1010
export { default as extractStatements } from './extract-statements'
11+
export { default as fillModules } from './fill-modules'
1112
export { default as findDynamicImports } from './find-dynamic-imports'
1213
export { default as findExports } from './find-exports'
1314
export { default as findRequires } from './find-requires'

0 commit comments

Comments
 (0)