Skip to content

Commit 296a319

Browse files
authored
feat: use unified-engine to load ESM configurations (#461)
* feat: use unified-engine to load ESM configurations close #436
1 parent 9a71318 commit 296a319

File tree

11 files changed

+87
-153
lines changed

11 files changed

+87
-153
lines changed

.changeset/quick-dolphins-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-mdx": minor
3+
---
4+
5+
feat: use unified-engine to load ESM configurations

packages/eslint-mdx/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
"dependencies": {
3333
"acorn": "^8.8.2",
3434
"acorn-jsx": "^5.3.2",
35-
"cosmiconfig": "^8.1.3",
3635
"espree": "^9.5.1",
3736
"estree-util-visit": "^1.2.1",
3837
"remark-mdx": "^2.3.0",
@@ -41,6 +40,7 @@
4140
"synckit": "^0.8.5",
4241
"tslib": "^2.5.0",
4342
"unified": "^10.1.2",
43+
"unified-engine": "^10.1.0",
4444
"unist-util-visit": "^4.1.2",
4545
"uvu": "^0.5.6",
4646
"vfile": "^5.3.7"

packages/eslint-mdx/src/helpers.ts

Lines changed: 0 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
/* eslint-disable unicorn/no-await-expression-member */
21
import fs from 'node:fs'
32
import path from 'node:path'
4-
import { pathToFileURL } from 'node:url'
53

64
import type { Position } from 'acorn'
75
import type { Point } from 'unist'
@@ -64,83 +62,6 @@ export const loadEsmModule = <T>(modulePath: URL | string): Promise<T> =>
6462
modulePath,
6563
) as Promise<T>
6664

67-
/**
68-
* Loads CJS and ESM modules based on extension
69-
* @param modulePath path to the module
70-
* @returns
71-
*/
72-
export const loadModule = async <T>(modulePath: string): Promise<T> => {
73-
const esModulePath = path.isAbsolute(modulePath)
74-
? pathToFileURL(modulePath)
75-
: modulePath
76-
switch (path.extname(modulePath)) {
77-
/* istanbul ignore next */
78-
case '.mjs': {
79-
return (await loadEsmModule<{ default: T }>(esModulePath)).default
80-
}
81-
/* istanbul ignore next */
82-
case '.cjs': {
83-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-return
84-
return require(modulePath)
85-
}
86-
default: {
87-
// The file could be either CommonJS or ESM.
88-
// CommonJS is tried first then ESM if loading fails.
89-
try {
90-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-return
91-
return require(modulePath)
92-
} catch (err) {
93-
const code = (err as { code: string }).code
94-
/* istanbul ignore if */
95-
if (
96-
code === 'ERR_REQUIRE_ESM' ||
97-
// A pure ESM could have no `exports.require` and then throw the following error,
98-
// related to #427.
99-
code === 'ERR_PACKAGE_PATH_NOT_EXPORTED'
100-
) {
101-
// Load the ESM configuration file using the TypeScript dynamic import workaround.
102-
// Once TypeScript provides support for keeping the dynamic import this workaround can be
103-
// changed to a direct dynamic import.
104-
return (await loadEsmModule<{ default: T }>(esModulePath)).default
105-
}
106-
107-
throw err
108-
}
109-
}
110-
}
111-
}
112-
113-
export const requirePkg = async <T>(
114-
plugin: string,
115-
prefix: string,
116-
filePath?: string,
117-
): Promise<T> => {
118-
let packages: string[]
119-
if (filePath && /^\.\.?(?:[/\\]|$)/.test(plugin)) {
120-
packages = [path.resolve(path.dirname(filePath), plugin)]
121-
} else {
122-
prefix = prefix.endsWith('-') ? prefix : prefix + '-'
123-
packages = [
124-
plugin,
125-
/* istanbul ignore next */
126-
plugin.startsWith('@')
127-
? plugin.replace('/', '/' + prefix)
128-
: prefix + plugin,
129-
]
130-
}
131-
let error: Error
132-
for (const pkg of packages) {
133-
try {
134-
return await loadModule<T>(pkg)
135-
} catch (err) {
136-
if (!error) {
137-
error = err as Error
138-
}
139-
}
140-
}
141-
throw error
142-
}
143-
14465
/* istanbul ignore next -- used in worker */
14566
export const getPositionAtFactory = (text: string) => {
14667
const lines = text.split('\n')

packages/eslint-mdx/src/types.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { Position } from 'acorn'
22
import type { AST, Linter } from 'eslint'
33
import type { Program } from 'estree'
44
import type { Root } from 'mdast'
5-
import type { Plugin } from 'unified'
65
import type { VFileOptions } from 'vfile'
76
import type { VFileMessage } from 'vfile-message'
87

@@ -23,13 +22,6 @@ export interface NormalPosition {
2322
range: [number, number]
2423
}
2524

26-
export type RemarkPlugin = Plugin | string
27-
28-
export interface RemarkConfig {
29-
settings: Record<string, string>
30-
plugins: Array<RemarkPlugin | [RemarkPlugin, ...unknown[]]>
31-
}
32-
3325
export interface WorkerOptions {
3426
fileOptions: VFileOptions
3527
physicalFilename: string

packages/eslint-mdx/src/worker.ts

Lines changed: 42 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import path from 'node:path'
44
import { pathToFileURL } from 'node:url'
55

66
import type { Token, TokenType, tokTypes as _tokTypes } from 'acorn'
7-
import { cosmiconfig } from 'cosmiconfig'
8-
import type { CosmiconfigResult } from 'cosmiconfig/dist/types'
97
import type { AST } from 'eslint'
108
import type { EsprimaToken } from 'espree/lib/token-translator'
119
import type {
@@ -26,27 +24,22 @@ import type {
2624
import type { Options } from 'micromark-extension-mdx-expression'
2725
import type { Root } from 'remark-mdx'
2826
import { extractProperties, runAsWorker } from 'synckit'
29-
import type { FrozenProcessor, Plugin } from 'unified'
27+
import type { FrozenProcessor } from 'unified'
28+
import type { Config, Configuration } from 'unified-engine/lib/configuration'
3029
import type { Node } from 'unist'
3130
import { ok as assert } from 'uvu/assert'
3231
import type { VFileMessage } from 'vfile-message'
3332

3433
import {
35-
arrayify,
3634
loadEsmModule,
3735
nextCharOffsetFactory,
3836
normalizePosition,
3937
prevCharOffsetFactory,
40-
requirePkg,
4138
} from './helpers'
4239
import { restoreTokens } from './tokens'
43-
import type {
44-
NormalPosition,
45-
RemarkConfig,
46-
RemarkPlugin,
47-
WorkerOptions,
48-
WorkerResult,
49-
} from './types'
40+
import type { NormalPosition, WorkerOptions, WorkerResult } from './types'
41+
42+
let config: Configuration
5043

5144
let acorn: typeof import('acorn')
5245
let acornJsx: {
@@ -60,12 +53,29 @@ let tt: Record<string, TokenType> & typeof _tokTypes
6053

6154
let TokenTranslator: typeof import('espree/lib/token-translator')['default']
6255

63-
const explorer = cosmiconfig('remark', {
64-
packageProp: 'remarkConfig',
65-
})
66-
6756
export const processorCache = new Map<string, FrozenProcessor>()
6857

58+
const getRemarkConfig = async (searchFrom: string) => {
59+
if (!config) {
60+
const { Configuration } = await loadEsmModule<
61+
typeof import('unified-engine/lib/configuration')
62+
>('unified-engine/lib/configuration.js')
63+
config = new Configuration({
64+
cwd: process.cwd(),
65+
packageField: 'remarkConfig',
66+
pluginPrefix: 'remark',
67+
rcName: '.remarkrc',
68+
detectConfig: true,
69+
})
70+
}
71+
72+
return new Promise<Config>((resolve, reject) =>
73+
config.load(searchFrom, (error, result) =>
74+
error ? reject(error) : resolve(result),
75+
),
76+
)
77+
}
78+
6979
const getRemarkMdxOptions = (tokens: Token[]): Options => ({
7080
acorn: acornParser,
7181
acornOptions: {
@@ -83,7 +93,6 @@ export const getRemarkProcessor = async (
8393
searchFrom: string,
8494
isMdx: boolean,
8595
ignoreRemarkConfig?: boolean,
86-
// eslint-disable-next-line sonarjs/cognitive-complexity
8796
) => {
8897
const initCacheKey = `${String(isMdx)}-${searchFrom}`
8998

@@ -93,12 +102,10 @@ export const getRemarkProcessor = async (
93102
return cachedProcessor
94103
}
95104

96-
const result: CosmiconfigResult = ignoreRemarkConfig
97-
? null
98-
: await explorer.search(searchFrom)
105+
const result = ignoreRemarkConfig ? null : await getRemarkConfig(searchFrom)
99106

100-
const cacheKey = result
101-
? `${String(isMdx)}-${result.filepath}`
107+
const cacheKey = result?.filePath
108+
? `${String(isMdx)}-${result.filePath}`
102109
: String(isMdx)
103110

104111
cachedProcessor = processorCache.get(cacheKey)
@@ -120,19 +127,23 @@ export const getRemarkProcessor = async (
120127

121128
const remarkProcessor = unified().use(remarkParse).freeze()
122129

123-
if (result) {
124-
/* istanbul ignore next */
125-
const { plugins = [], settings } =
126-
// type-coverage:ignore-next-line -- cosmiconfig's typings issue
127-
(result.config || {}) as Partial<RemarkConfig>
130+
if (result?.filePath) {
131+
const { plugins, settings } = result
128132

129133
// disable this rule automatically since we already have a parser option `extensions`
130134
// only disable this plugin if there are at least one plugin enabled
131135
// otherwise it is redundant
132136
/* istanbul ignore else */
133137
if (plugins.length > 0) {
134138
try {
135-
plugins.push([await requirePkg('lint-file-extension', 'remark'), false])
139+
plugins.push([
140+
(
141+
await loadEsmModule<typeof import('remark-lint-file-extension')>(
142+
'remark-lint-file-extension',
143+
)
144+
).default,
145+
false,
146+
])
136147
} catch {
137148
// just ignore if the package does not exist
138149
}
@@ -146,21 +157,9 @@ export const getRemarkProcessor = async (
146157
initProcessor.use(remarkMdx, getRemarkMdxOptions(sharedTokens))
147158
}
148159

149-
cachedProcessor = (
150-
await plugins.reduce(async (processor, pluginWithSettings) => {
151-
const [plugin, ...pluginSettings] = arrayify(pluginWithSettings) as [
152-
RemarkPlugin,
153-
...unknown[],
154-
]
155-
return (await processor).use(
156-
/* istanbul ignore next */
157-
typeof plugin === 'string'
158-
? await requirePkg<Plugin>(plugin, 'remark', result.filepath)
159-
: plugin,
160-
...pluginSettings,
161-
)
162-
}, Promise.resolve(initProcessor))
163-
).freeze()
160+
cachedProcessor = plugins
161+
.reduce((processor, plugin) => processor.use(...plugin), initProcessor)
162+
.freeze()
164163
} else {
165164
const initProcessor = remarkProcessor().use(remarkStringify)
166165

test/__snapshots__/fixtures.test.ts.snap

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,36 @@ exports[`fixtures should match all snapshots: remark.md 1`] = `[]`;
11451145
11461146
exports[`fixtures should match all snapshots: remark.mdx 1`] = `[]`;
11471147
1148+
exports[`fixtures should match all snapshots: test.md 1`] = `
1149+
[
1150+
{
1151+
"column": 1,
1152+
"endColumn": 15,
1153+
"endLine": 1,
1154+
"line": 1,
1155+
"message": "Link to unknown file: \`test\`",
1156+
"nodeType": "Program",
1157+
"ruleId": "remark-validate-links-missing-file",
1158+
"severity": 1,
1159+
},
1160+
]
1161+
`;
1162+
1163+
exports[`fixtures should match all snapshots: test.md 2`] = `
1164+
[
1165+
{
1166+
"column": 1,
1167+
"endColumn": 15,
1168+
"endLine": 1,
1169+
"line": 1,
1170+
"message": "Link to unknown file: \`test\`",
1171+
"nodeType": "Program",
1172+
"ruleId": "remark-validate-links-missing-file",
1173+
"severity": 1,
1174+
},
1175+
]
1176+
`;
1177+
11481178
exports[`fixtures should match all snapshots: unicorn.jsx 1`] = `
11491179
[
11501180
{

test/fixtures/async/test.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[test](./test)

test/fixtures/esm/.remarkrc.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import validateLinks from 'remark-validate-links'
2+
3+
export default {
4+
plugins: [[validateLinks, 2]],
5+
}

test/fixtures/esm/test.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[test](./test)

test/helpers.test.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import path from 'node:path'
2-
3-
import { arrayify, requirePkg } from 'eslint-mdx'
1+
import { arrayify } from 'eslint-mdx'
42
import { getGlobals, getShortLang } from 'eslint-plugin-mdx'
53

64
describe('Helpers', () => {
@@ -25,22 +23,4 @@ describe('Helpers', () => {
2523
b: false,
2624
})
2725
})
28-
29-
it('should resolve package correctly', async () => {
30-
expect(await requirePkg('@1stg/config', 'commitlint')).toBeDefined()
31-
// expect(await requirePkg('lint', 'remark')).toBeDefined()
32-
// expect(await requirePkg('remark-parse', 'non-existed')).toBeDefined()
33-
expect(
34-
await requirePkg(
35-
'./.eslintrc',
36-
'non-existed',
37-
path.resolve('package.json'),
38-
),
39-
).toBeDefined()
40-
})
41-
42-
it('should throw on non existed package', () =>
43-
expect(requirePkg('@1stg/x-config', 'unexpected-')).rejects.toThrow(
44-
"Cannot find module '@1stg/x-config'",
45-
))
4626
})

0 commit comments

Comments
 (0)