@soybeanjs/oxc-config 0.0.5 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +33 -5
- package/README.md +33 -5
- package/dist/index.js +17 -3
- package/dist/plugins/import.d.ts +67 -0
- package/dist/plugins/import.js +288 -0
- package/package.json +6 -5
- package/dist/import-type-order.js +0 -112
package/README.en.md
CHANGED
|
@@ -12,7 +12,7 @@ Shared [oxlint](https://oxc.rs/docs/guide/usage/linter.html) and [oxfmt](https:/
|
|
|
12
12
|
- 🚀 **oxfmt formatter config** — Unified code style with fine-grained import sorting groups
|
|
13
13
|
- 🔍 **oxlint linter config** — Integrated with ESLint / TypeScript / Unicorn / Vue plugins
|
|
14
14
|
- 📦 **Import sorting** — Auto-group and sort imports by dependency layer
|
|
15
|
-
- 🎯 **
|
|
15
|
+
- 🎯 **custom import plugin** — Includes auto-merging duplicate imports, disallowing inline type imports, and enforcing value imports before type imports
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
@@ -109,9 +109,35 @@ export default lint;
|
|
|
109
109
|
- **import** — Import/Export rules
|
|
110
110
|
- **vue** — Vue SFC rules
|
|
111
111
|
|
|
112
|
-
#### Custom
|
|
112
|
+
#### Custom Rules: soybeanjs-import
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
This config ships with a custom oxlint JS plugin loaded from `@soybeanjs/oxc-config/plugins/import`, with 3 built-in rules:
|
|
115
|
+
|
|
116
|
+
- `soybeanjs-import/merge-duplicates`: keep at most one value import and one type import per module, and auto-merge duplicate imports
|
|
117
|
+
- `soybeanjs-import/no-inline-type-import`: disallow inline type imports like `import { type Foo }`, and auto-convert them to top-level `import type`
|
|
118
|
+
- `soybeanjs-import/type-after-value`: ensure value imports come before type imports for the same module
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// ✅ After fixing
|
|
124
|
+
import { a, b } from './types';
|
|
125
|
+
import type { DemoTest } from './types';
|
|
126
|
+
|
|
127
|
+
// ❌ Duplicate imports
|
|
128
|
+
import { b } from './types';
|
|
129
|
+
import { a } from './types';
|
|
130
|
+
import type { DemoTest } from './types';
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// ✅ After fixing
|
|
135
|
+
import { a } from './types';
|
|
136
|
+
import type { DemoTest } from './types';
|
|
137
|
+
|
|
138
|
+
// ❌ Inline type import
|
|
139
|
+
import { a, type DemoTest } from './types';
|
|
140
|
+
```
|
|
115
141
|
|
|
116
142
|
```ts
|
|
117
143
|
// ✅ Correct
|
|
@@ -130,9 +156,11 @@ import { lint } from '@soybeanjs/oxc-config';
|
|
|
130
156
|
|
|
131
157
|
export default {
|
|
132
158
|
...lint,
|
|
133
|
-
jsPlugins: ['@soybeanjs/oxc-config/import
|
|
159
|
+
jsPlugins: ['@soybeanjs/oxc-config/plugins/import'],
|
|
134
160
|
rules: {
|
|
135
|
-
'import
|
|
161
|
+
'soybeanjs-import/merge-duplicates': 'warn',
|
|
162
|
+
'soybeanjs-import/no-inline-type-import': 'warn',
|
|
163
|
+
'soybeanjs-import/type-after-value': 'warn'
|
|
136
164
|
}
|
|
137
165
|
};
|
|
138
166
|
```
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ SoybeanJS 的共享 [oxlint](https://oxc.rs/docs/guide/usage/linter.html) 和 [o
|
|
|
12
12
|
- 🚀 **oxfmt 格式化配置** — 统一代码风格,包含精细的 import 排序分组
|
|
13
13
|
- 🔍 **oxlint 代码检查** — 集成 ESLint / TypeScript / Unicorn / Vue 等插件
|
|
14
14
|
- 📦 **Import 排序** — 按依赖层级自动分组排序 external 和 internal import
|
|
15
|
-
- 🎯
|
|
15
|
+
- 🎯 **自定义 import 插件** — 附带自动合并重复导入、禁止 inline type import、保证 value import 在 type import 之前
|
|
16
16
|
|
|
17
17
|
## 安装
|
|
18
18
|
|
|
@@ -109,9 +109,35 @@ export default lint;
|
|
|
109
109
|
- **import** — Import/Export 规则
|
|
110
110
|
- **vue** — Vue SFC 规则
|
|
111
111
|
|
|
112
|
-
#### 自定义规则:import
|
|
112
|
+
#### 自定义规则:soybeanjs-import
|
|
113
113
|
|
|
114
|
-
本配置附带一个 oxlint JS
|
|
114
|
+
本配置附带一个 oxlint JS 插件,加载路径为 `@soybeanjs/oxc-config/plugins/import`,内置 3 条规则:
|
|
115
|
+
|
|
116
|
+
- `soybeanjs-import/merge-duplicates`:同一模块最多保留一条 value import 和一条 type import,并自动合并重复导入
|
|
117
|
+
- `soybeanjs-import/no-inline-type-import`:禁止 `import { type Foo }` 这种 inline type import,自动改成顶层 `import type`
|
|
118
|
+
- `soybeanjs-import/type-after-value`:同一模块内要求 value import 在前,type import 在后
|
|
119
|
+
|
|
120
|
+
示例:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// ✅ 修复后
|
|
124
|
+
import { a, b } from './types';
|
|
125
|
+
import type { DemoTest } from './types';
|
|
126
|
+
|
|
127
|
+
// ❌ 重复导入
|
|
128
|
+
import { b } from './types';
|
|
129
|
+
import { a } from './types';
|
|
130
|
+
import type { DemoTest } from './types';
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// ✅ 修复后
|
|
135
|
+
import { a } from './types';
|
|
136
|
+
import type { DemoTest } from './types';
|
|
137
|
+
|
|
138
|
+
// ❌ inline type import
|
|
139
|
+
import { a, type DemoTest } from './types';
|
|
140
|
+
```
|
|
115
141
|
|
|
116
142
|
```ts
|
|
117
143
|
// ✅ 正确
|
|
@@ -132,9 +158,11 @@ import { lint } from '@soybeanjs/oxc-config';
|
|
|
132
158
|
|
|
133
159
|
export default {
|
|
134
160
|
...lint,
|
|
135
|
-
jsPlugins: ['@soybeanjs/oxc-config/import
|
|
161
|
+
jsPlugins: ['@soybeanjs/oxc-config/plugins/import'],
|
|
136
162
|
rules: {
|
|
137
|
-
'import
|
|
163
|
+
'soybeanjs-import/merge-duplicates': 'warn',
|
|
164
|
+
'soybeanjs-import/no-inline-type-import': 'warn',
|
|
165
|
+
'soybeanjs-import/type-after-value': 'warn'
|
|
138
166
|
}
|
|
139
167
|
};
|
|
140
168
|
```
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,9 @@ var external = [
|
|
|
19
19
|
"@vueuse/**",
|
|
20
20
|
"vue-**",
|
|
21
21
|
"nuxt",
|
|
22
|
+
"hono",
|
|
23
|
+
"@hono/**",
|
|
24
|
+
"hono-**",
|
|
22
25
|
"@nuxt/**",
|
|
23
26
|
"nuxt-**",
|
|
24
27
|
"react",
|
|
@@ -50,6 +53,10 @@ var external = [
|
|
|
50
53
|
"@arco-design/**",
|
|
51
54
|
"@iconify/**",
|
|
52
55
|
"axios",
|
|
56
|
+
"valibot",
|
|
57
|
+
"@valibot/**",
|
|
58
|
+
"zod",
|
|
59
|
+
"@zod/**",
|
|
53
60
|
"date-fns",
|
|
54
61
|
"dayjs",
|
|
55
62
|
"es-toolkit",
|
|
@@ -207,12 +214,19 @@ const lint = {
|
|
|
207
214
|
"import",
|
|
208
215
|
"vue"
|
|
209
216
|
],
|
|
210
|
-
jsPlugins: ["@soybeanjs/oxc-config/import
|
|
217
|
+
jsPlugins: ["@soybeanjs/oxc-config/plugins/import"],
|
|
211
218
|
rules: {
|
|
219
|
+
"soybeanjs-import/merge-duplicates": "warn",
|
|
220
|
+
"soybeanjs-import/no-inline-type-import": "warn",
|
|
221
|
+
"soybeanjs-import/type-after-value": "warn",
|
|
222
|
+
"import/newline-after-import": "warn",
|
|
212
223
|
"import/no-unassigned-import": "off",
|
|
213
|
-
"import-type-order/type-after-value": "warn",
|
|
214
224
|
"no-underscore-dangle": "off",
|
|
215
|
-
"
|
|
225
|
+
"typescript/consistent-type-imports": ["error", {
|
|
226
|
+
disallowTypeAnnotations: false,
|
|
227
|
+
prefer: "type-imports",
|
|
228
|
+
fixStyle: "separate-type-imports"
|
|
229
|
+
}],
|
|
216
230
|
"unicorn/consistent-function-scoping": "off",
|
|
217
231
|
"unicorn/no-array-reverse": "off",
|
|
218
232
|
"unicorn/no-array-sort": "off",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/plugins/import/shared/types.d.ts
|
|
2
|
+
interface Position {
|
|
3
|
+
line: number;
|
|
4
|
+
}
|
|
5
|
+
interface SourceLocation {
|
|
6
|
+
start: Position;
|
|
7
|
+
end: Position;
|
|
8
|
+
}
|
|
9
|
+
interface BaseNode {
|
|
10
|
+
type: string;
|
|
11
|
+
range: [number, number];
|
|
12
|
+
loc: SourceLocation;
|
|
13
|
+
}
|
|
14
|
+
interface CommentNode {
|
|
15
|
+
range: [number, number];
|
|
16
|
+
}
|
|
17
|
+
interface ProgramNode extends BaseNode {
|
|
18
|
+
body: BaseNode[];
|
|
19
|
+
}
|
|
20
|
+
interface SourceCodeLike {
|
|
21
|
+
text: string;
|
|
22
|
+
ast: ProgramNode;
|
|
23
|
+
getText(node?: {
|
|
24
|
+
range: [number, number];
|
|
25
|
+
} | null): string;
|
|
26
|
+
getCommentsBefore(node: BaseNode): CommentNode[];
|
|
27
|
+
}
|
|
28
|
+
interface FixerLike {
|
|
29
|
+
replaceTextRange(range: [number, number], text: string): unknown;
|
|
30
|
+
replaceText(node: BaseNode, text: string): unknown;
|
|
31
|
+
}
|
|
32
|
+
interface ReportDescriptor {
|
|
33
|
+
node: BaseNode;
|
|
34
|
+
messageId: string;
|
|
35
|
+
data?: Record<string, string>;
|
|
36
|
+
fix?: (fixer: FixerLike) => unknown;
|
|
37
|
+
}
|
|
38
|
+
interface RuleContextLike {
|
|
39
|
+
sourceCode: SourceCodeLike;
|
|
40
|
+
report(descriptor: ReportDescriptor): void;
|
|
41
|
+
}
|
|
42
|
+
type VisitorLike = Record<string, ((node: any) => void) | undefined>;
|
|
43
|
+
interface RuleMetaLike {
|
|
44
|
+
type: string;
|
|
45
|
+
fixable?: 'code';
|
|
46
|
+
docs?: {
|
|
47
|
+
description: string;
|
|
48
|
+
url?: string;
|
|
49
|
+
};
|
|
50
|
+
messages?: Record<string, string>;
|
|
51
|
+
schema?: unknown[];
|
|
52
|
+
}
|
|
53
|
+
interface RuleLike {
|
|
54
|
+
meta?: RuleMetaLike;
|
|
55
|
+
create: (context: RuleContextLike) => VisitorLike;
|
|
56
|
+
}
|
|
57
|
+
interface PluginLike {
|
|
58
|
+
meta: {
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
rules: Record<string, RuleLike>;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/plugins/import/index.d.ts
|
|
65
|
+
declare const plugin: PluginLike;
|
|
66
|
+
//#endregion
|
|
67
|
+
export { plugin as default };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
//#region src/plugins/import/shared/imports.ts
|
|
2
|
+
function isTypeKind(kind) {
|
|
3
|
+
return kind === "type" || kind === "typeof";
|
|
4
|
+
}
|
|
5
|
+
function isImportDeclaration(node) {
|
|
6
|
+
return Boolean(node) && typeof node === "object" && node.type === "ImportDeclaration";
|
|
7
|
+
}
|
|
8
|
+
function isTypeImport(node) {
|
|
9
|
+
return isTypeKind(node.importKind);
|
|
10
|
+
}
|
|
11
|
+
function isInlineTypeSpecifier(node) {
|
|
12
|
+
return node.type === "ImportSpecifier" && isTypeKind(node.importKind);
|
|
13
|
+
}
|
|
14
|
+
function getInlineTypeSpecifiers(node) {
|
|
15
|
+
return (node.specifiers ?? []).filter(isInlineTypeSpecifier);
|
|
16
|
+
}
|
|
17
|
+
function hasInlineTypeSpecifier(node) {
|
|
18
|
+
return !isTypeImport(node) && getInlineTypeSpecifiers(node).length > 0;
|
|
19
|
+
}
|
|
20
|
+
function stripInlineTypeKeyword(text) {
|
|
21
|
+
return text.replace(/^type\s+/, "");
|
|
22
|
+
}
|
|
23
|
+
function buildImportStatement(options) {
|
|
24
|
+
const { importKeyword, specifierTexts, sourceText } = options;
|
|
25
|
+
return `${importKeyword} ${specifierTexts.join(", ")} from ${sourceText};`;
|
|
26
|
+
}
|
|
27
|
+
function buildNamedImportText(specifiers, sourceCode, options = {}) {
|
|
28
|
+
return `{ ${specifiers.map((specifier) => {
|
|
29
|
+
const text = sourceCode.getText(specifier);
|
|
30
|
+
return options.stripTypeKeyword ? stripInlineTypeKeyword(text) : text;
|
|
31
|
+
}).join(", ")} }`;
|
|
32
|
+
}
|
|
33
|
+
function buildValueImportStatement(node, sourceCode) {
|
|
34
|
+
const specifiers = node.specifiers ?? [];
|
|
35
|
+
const defaultSpecifiers = specifiers.filter((specifier) => {
|
|
36
|
+
return specifier.type === "ImportDefaultSpecifier";
|
|
37
|
+
}).map((specifier) => sourceCode.getText(specifier));
|
|
38
|
+
const namespaceSpecifiers = specifiers.filter((specifier) => {
|
|
39
|
+
return specifier.type === "ImportNamespaceSpecifier";
|
|
40
|
+
}).map((specifier) => sourceCode.getText(specifier));
|
|
41
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === "ImportSpecifier" && !isInlineTypeSpecifier(specifier));
|
|
42
|
+
const specifierTexts = [...defaultSpecifiers, ...namespaceSpecifiers];
|
|
43
|
+
if (namedSpecifiers.length > 0) specifierTexts.push(buildNamedImportText(namedSpecifiers, sourceCode));
|
|
44
|
+
if (specifierTexts.length === 0) return null;
|
|
45
|
+
return buildImportStatement({
|
|
46
|
+
importKeyword: "import",
|
|
47
|
+
specifierTexts,
|
|
48
|
+
sourceText: sourceCode.getText(node.source)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function buildTypeImportStatement(node, sourceCode) {
|
|
52
|
+
const inlineTypeSpecifiers = getInlineTypeSpecifiers(node);
|
|
53
|
+
if (inlineTypeSpecifiers.length === 0) return null;
|
|
54
|
+
return buildImportStatement({
|
|
55
|
+
importKeyword: "import type",
|
|
56
|
+
specifierTexts: [buildNamedImportText(inlineTypeSpecifiers, sourceCode, { stripTypeKeyword: true })],
|
|
57
|
+
sourceText: sourceCode.getText(node.source)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function canFixInlineTypeImport(node) {
|
|
61
|
+
return !isTypeImport(node) && node.specifiers != null && hasInlineTypeSpecifier(node) && node.withClause == null;
|
|
62
|
+
}
|
|
63
|
+
function createImportStatementParts() {
|
|
64
|
+
return {
|
|
65
|
+
defaultImports: [],
|
|
66
|
+
namespaceImports: [],
|
|
67
|
+
namedImports: []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function pushUnique(values, text) {
|
|
71
|
+
if (!values.includes(text)) values.push(text);
|
|
72
|
+
}
|
|
73
|
+
function appendSpecifierText(target, specifier, sourceCode) {
|
|
74
|
+
if (specifier.type === "ImportDefaultSpecifier") {
|
|
75
|
+
pushUnique(target.defaultImports, sourceCode.getText(specifier));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (specifier.type === "ImportNamespaceSpecifier") {
|
|
79
|
+
pushUnique(target.namespaceImports, sourceCode.getText(specifier));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
pushUnique(target.namedImports, stripInlineTypeKeyword(sourceCode.getText(specifier)));
|
|
83
|
+
}
|
|
84
|
+
function canBuildSingleImport(parts) {
|
|
85
|
+
if (parts.defaultImports.length > 1 || parts.namespaceImports.length > 1) return false;
|
|
86
|
+
if (parts.namespaceImports.length > 0 && parts.namedImports.length > 0) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
function buildImportFromParts(parts, sourceText, importKeyword) {
|
|
90
|
+
if (!canBuildSingleImport(parts)) return null;
|
|
91
|
+
const specifierTexts = [...parts.defaultImports, ...parts.namespaceImports];
|
|
92
|
+
if (parts.namedImports.length > 0) specifierTexts.push(`{ ${parts.namedImports.join(", ")} }`);
|
|
93
|
+
if (specifierTexts.length === 0) return null;
|
|
94
|
+
return buildImportStatement({
|
|
95
|
+
importKeyword,
|
|
96
|
+
specifierTexts,
|
|
97
|
+
sourceText
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function mergeImportGroup(group, sourceCode) {
|
|
101
|
+
if (group.length < 2) return null;
|
|
102
|
+
const valueParts = createImportStatementParts();
|
|
103
|
+
const typeParts = createImportStatementParts();
|
|
104
|
+
const sourceText = sourceCode.getText(group[0].node.source);
|
|
105
|
+
for (const item of group) {
|
|
106
|
+
if (item.node.withClause != null || (item.node.specifiers?.length ?? 0) === 0) return null;
|
|
107
|
+
if (sourceCode.getCommentsBefore(item.node).length > 0 && item !== group[0]) return null;
|
|
108
|
+
for (const specifier of item.node.specifiers ?? []) if (isTypeImport(item.node) || isInlineTypeSpecifier(specifier)) appendSpecifierText(typeParts, specifier, sourceCode);
|
|
109
|
+
else appendSpecifierText(valueParts, specifier, sourceCode);
|
|
110
|
+
}
|
|
111
|
+
const mergedStatements = [buildImportFromParts(valueParts, sourceText, "import"), buildImportFromParts(typeParts, sourceText, "import type")].filter((statement) => Boolean(statement));
|
|
112
|
+
if (mergedStatements.length === 0 || mergedStatements.length >= group.length) return null;
|
|
113
|
+
return mergedStatements;
|
|
114
|
+
}
|
|
115
|
+
function collectImportGroups(sourceCode) {
|
|
116
|
+
const allImports = [];
|
|
117
|
+
for (let index = 0; index < sourceCode.ast.body.length; index += 1) {
|
|
118
|
+
const node = sourceCode.ast.body[index];
|
|
119
|
+
if (isImportDeclaration(node)) allImports.push({
|
|
120
|
+
node,
|
|
121
|
+
bodyIndex: index
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (allImports.length < 2) return [];
|
|
125
|
+
const groups = [];
|
|
126
|
+
let currentGroup = [allImports[0]];
|
|
127
|
+
for (let index = 1; index < allImports.length; index += 1) {
|
|
128
|
+
const previous = allImports[index - 1];
|
|
129
|
+
const current = allImports[index];
|
|
130
|
+
const linesBetween = current.node.loc.start.line - previous.node.loc.end.line;
|
|
131
|
+
if (previous.node.source.value === current.node.source.value && linesBetween <= 1) currentGroup.push(current);
|
|
132
|
+
else {
|
|
133
|
+
groups.push(currentGroup);
|
|
134
|
+
currentGroup = [current];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
groups.push(currentGroup);
|
|
138
|
+
return groups;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/plugins/import/shared/source.ts
|
|
143
|
+
function getStartWithComments(node, sourceCode) {
|
|
144
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
145
|
+
if (comments.length > 0) return comments[0].range[0];
|
|
146
|
+
return node.range[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/plugins/import/rules/merge-duplicates.ts
|
|
151
|
+
const mergeDuplicatesRule = {
|
|
152
|
+
meta: {
|
|
153
|
+
type: "layout",
|
|
154
|
+
fixable: "code",
|
|
155
|
+
docs: {
|
|
156
|
+
description: "Merge duplicate imports from the same module into at most one value import and one type import.",
|
|
157
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/merge-duplicates.ts"
|
|
158
|
+
},
|
|
159
|
+
messages: { mergeDuplicates: "Imports from \"{{source}}\" should be merged into at most one value import and one type import." },
|
|
160
|
+
schema: []
|
|
161
|
+
},
|
|
162
|
+
create(context) {
|
|
163
|
+
const sourceCode = context.sourceCode;
|
|
164
|
+
return { "Program:exit"() {
|
|
165
|
+
const groups = collectImportGroups(sourceCode);
|
|
166
|
+
for (const group of groups) {
|
|
167
|
+
const mergedStatements = mergeImportGroup(group, sourceCode);
|
|
168
|
+
if (!mergedStatements) continue;
|
|
169
|
+
const firstNode = group[0].node;
|
|
170
|
+
const lastNode = group[group.length - 1].node;
|
|
171
|
+
context.report({
|
|
172
|
+
node: firstNode,
|
|
173
|
+
messageId: "mergeDuplicates",
|
|
174
|
+
data: { source: firstNode.source.value },
|
|
175
|
+
fix(fixer) {
|
|
176
|
+
return fixer.replaceTextRange([getStartWithComments(firstNode, sourceCode), lastNode.range[1]], mergedStatements.join("\n"));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/plugins/import/rules/no-inline-type-import.ts
|
|
186
|
+
const noInlineTypeImportRule = {
|
|
187
|
+
meta: {
|
|
188
|
+
type: "layout",
|
|
189
|
+
fixable: "code",
|
|
190
|
+
docs: {
|
|
191
|
+
description: "Prefer top-level `import type` over inline `type` specifiers, including mixed value/type imports.",
|
|
192
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/no-inline-type-import.ts"
|
|
193
|
+
},
|
|
194
|
+
messages: { noInlineTypeImport: "Inline type import from \"{{source}}\" should use top-level `import type` declarations instead." },
|
|
195
|
+
schema: []
|
|
196
|
+
},
|
|
197
|
+
create(context) {
|
|
198
|
+
const sourceCode = context.sourceCode;
|
|
199
|
+
return { ImportDeclaration(node) {
|
|
200
|
+
if (!canFixInlineTypeImport(node)) return;
|
|
201
|
+
context.report({
|
|
202
|
+
node,
|
|
203
|
+
messageId: "noInlineTypeImport",
|
|
204
|
+
data: { source: node.source.value },
|
|
205
|
+
fix(fixer) {
|
|
206
|
+
const parts = [buildValueImportStatement(node, sourceCode), buildTypeImportStatement(node, sourceCode)].filter(Boolean);
|
|
207
|
+
return fixer.replaceText(node, parts.join("\n"));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} };
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/plugins/import/rules/type-after-value.ts
|
|
216
|
+
const typeAfterValueRule = {
|
|
217
|
+
meta: {
|
|
218
|
+
type: "layout",
|
|
219
|
+
fixable: "code",
|
|
220
|
+
docs: {
|
|
221
|
+
description: "Ensure value imports come before type imports for the same module source.",
|
|
222
|
+
url: "https://github.com/soybeanjs/oxc-config/blob/main/src/plugins/import/rules/type-after-value.ts"
|
|
223
|
+
},
|
|
224
|
+
messages: { typeBeforeValue: "Type import from \"{{source}}\" must be placed after its value import." },
|
|
225
|
+
schema: []
|
|
226
|
+
},
|
|
227
|
+
create(context) {
|
|
228
|
+
const sourceCode = context.sourceCode;
|
|
229
|
+
return { "Program:exit"() {
|
|
230
|
+
const groups = collectImportGroups(sourceCode);
|
|
231
|
+
for (const group of groups) {
|
|
232
|
+
if (group.length < 2) continue;
|
|
233
|
+
if (!group.some((item, index) => {
|
|
234
|
+
if (!isTypeImport(item.node)) return false;
|
|
235
|
+
for (let cursor = index + 1; cursor < group.length; cursor += 1) if (!isTypeImport(group[cursor].node)) return true;
|
|
236
|
+
return false;
|
|
237
|
+
})) continue;
|
|
238
|
+
const sorted = [...group].sort((left, right) => {
|
|
239
|
+
const leftIsType = isTypeImport(left.node);
|
|
240
|
+
if (leftIsType === isTypeImport(right.node)) return 0;
|
|
241
|
+
return leftIsType ? 1 : -1;
|
|
242
|
+
});
|
|
243
|
+
const firstNode = group[0].node;
|
|
244
|
+
const lastNode = group[group.length - 1].node;
|
|
245
|
+
context.report({
|
|
246
|
+
node: firstNode,
|
|
247
|
+
messageId: "typeBeforeValue",
|
|
248
|
+
data: { source: firstNode.source.value },
|
|
249
|
+
fix(fixer) {
|
|
250
|
+
const parts = [];
|
|
251
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
252
|
+
const item = sorted[index];
|
|
253
|
+
const previousItem = index > 0 ? sorted[index - 1] : null;
|
|
254
|
+
const start = getStartWithComments(item.node, sourceCode);
|
|
255
|
+
const text = sourceCode.text.slice(start, item.node.range[1]);
|
|
256
|
+
if (index > 0 && previousItem) {
|
|
257
|
+
const previousOriginalIndex = group.findIndex((candidate) => candidate === previousItem);
|
|
258
|
+
const currentOriginalIndex = group.findIndex((candidate) => candidate === item);
|
|
259
|
+
if (Math.abs(currentOriginalIndex - previousOriginalIndex) === 1) {
|
|
260
|
+
const earlier = group[Math.min(previousOriginalIndex, currentOriginalIndex)];
|
|
261
|
+
const later = group[Math.max(previousOriginalIndex, currentOriginalIndex)];
|
|
262
|
+
const separator = sourceCode.text.slice(earlier.node.range[1], getStartWithComments(later.node, sourceCode));
|
|
263
|
+
parts.push(separator);
|
|
264
|
+
} else parts.push("\n");
|
|
265
|
+
}
|
|
266
|
+
parts.push(text);
|
|
267
|
+
}
|
|
268
|
+
return fixer.replaceTextRange([getStartWithComments(firstNode, sourceCode), lastNode.range[1]], parts.join(""));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
} };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/plugins/import/index.ts
|
|
278
|
+
const plugin = {
|
|
279
|
+
meta: { name: "soybeanjs-import" },
|
|
280
|
+
rules: {
|
|
281
|
+
"merge-duplicates": mergeDuplicatesRule,
|
|
282
|
+
"no-inline-type-import": noInlineTypeImportRule,
|
|
283
|
+
"type-after-value": typeAfterValueRule
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
export { plugin as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soybeanjs/oxc-config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "SoybeanJS's oxlint and oxfmt config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
"import": "./dist/index.js",
|
|
36
36
|
"require": "./dist/index.js"
|
|
37
37
|
},
|
|
38
|
-
"./
|
|
39
|
-
"
|
|
40
|
-
"
|
|
38
|
+
"./plugins/*": {
|
|
39
|
+
"types": "./dist/plugins/*.d.ts",
|
|
40
|
+
"import": "./dist/plugins/*.js",
|
|
41
|
+
"require": "./dist/plugins/*.js"
|
|
41
42
|
}
|
|
42
43
|
},
|
|
43
44
|
"publishConfig": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@soybeanjs/cli": "^1.7.2",
|
|
49
50
|
"@soybeanjs/oxc-config": "link:dist",
|
|
50
|
-
"@types/node": "^25.9.
|
|
51
|
+
"@types/node": "^25.9.1",
|
|
51
52
|
"lint-staged": "^17.0.5",
|
|
52
53
|
"oxfmt": "^0.51.0",
|
|
53
54
|
"oxlint": "^1.66.0",
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
//#region src/import-type-order.js
|
|
2
|
-
/**
|
|
3
|
-
* oxlint JS plugin: import-type-order
|
|
4
|
-
*
|
|
5
|
-
* 确保同一模块的 `import type` 始终在 `import` (value import) 之后。
|
|
6
|
-
* 与 oxfmt 的 sortImports 配合使用:oxfmt 负责分组排序,此插件负责同模块 value-before-type。
|
|
7
|
-
*
|
|
8
|
-
* 基于 eslint-plugin-simple-import-sort 的核心思路实现。
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* 判断 import 是否为 type-only import(`import type { X } from '...'`)
|
|
12
|
-
* 也兼容 `import typeof`(Flow 语法)。
|
|
13
|
-
*/
|
|
14
|
-
function isTypeImport(node) {
|
|
15
|
-
return node.importKind === "type" || node.importKind === "typeof";
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* 获取 import 声明前附着的注释的起始位置。
|
|
19
|
-
* 如果没有前导注释,返回节点自身的起始位置。
|
|
20
|
-
*/
|
|
21
|
-
function getStartWithComments(node, sourceCode) {
|
|
22
|
-
const comments = sourceCode.getCommentsBefore(node);
|
|
23
|
-
if (comments.length > 0) return comments[0].range[0];
|
|
24
|
-
return node.range[0];
|
|
25
|
-
}
|
|
26
|
-
const plugin = {
|
|
27
|
-
meta: { name: "import-type-order" },
|
|
28
|
-
rules: { "type-after-value": {
|
|
29
|
-
meta: {
|
|
30
|
-
type: "layout",
|
|
31
|
-
fixable: "code",
|
|
32
|
-
docs: {
|
|
33
|
-
description: "Ensure value imports come before type imports for the same module source.",
|
|
34
|
-
url: "https://github.com/soybeanjs/oxfmt-import-order"
|
|
35
|
-
},
|
|
36
|
-
messages: { typeBeforeValue: "Type import from \"{{source}}\" must be placed after its value import." },
|
|
37
|
-
schema: []
|
|
38
|
-
},
|
|
39
|
-
create(context) {
|
|
40
|
-
const sourceCode = context.sourceCode;
|
|
41
|
-
return { "Program:exit"() {
|
|
42
|
-
const body = sourceCode.ast.body;
|
|
43
|
-
const allImports = [];
|
|
44
|
-
for (let i = 0; i < body.length; i++) if (body[i].type === "ImportDeclaration") allImports.push({
|
|
45
|
-
node: body[i],
|
|
46
|
-
bodyIndex: i
|
|
47
|
-
});
|
|
48
|
-
if (allImports.length < 2) return;
|
|
49
|
-
const groups = [];
|
|
50
|
-
let currentGroup = [allImports[0]];
|
|
51
|
-
for (let i = 1; i < allImports.length; i++) {
|
|
52
|
-
const prev = allImports[i - 1];
|
|
53
|
-
const curr = allImports[i];
|
|
54
|
-
const linesBetween = curr.node.loc.start.line - prev.node.loc.end.line;
|
|
55
|
-
if (prev.node.source.value === curr.node.source.value && linesBetween <= 1) currentGroup.push(curr);
|
|
56
|
-
else {
|
|
57
|
-
groups.push(currentGroup);
|
|
58
|
-
currentGroup = [curr];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
groups.push(currentGroup);
|
|
62
|
-
for (const group of groups) {
|
|
63
|
-
if (group.length < 2) continue;
|
|
64
|
-
if (!group.some((item, i) => {
|
|
65
|
-
if (!isTypeImport(item.node)) return false;
|
|
66
|
-
for (let j = i + 1; j < group.length; j++) if (!isTypeImport(group[j].node)) return true;
|
|
67
|
-
return false;
|
|
68
|
-
})) continue;
|
|
69
|
-
const sorted = [...group].sort((a, b) => {
|
|
70
|
-
const aIsType = isTypeImport(a.node);
|
|
71
|
-
if (aIsType === isTypeImport(b.node)) return 0;
|
|
72
|
-
return aIsType ? 1 : -1;
|
|
73
|
-
});
|
|
74
|
-
const firstNode = group[0].node;
|
|
75
|
-
const lastNode = group[group.length - 1].node;
|
|
76
|
-
const source = firstNode.source.value;
|
|
77
|
-
context.report({
|
|
78
|
-
node: firstNode,
|
|
79
|
-
messageId: "typeBeforeValue",
|
|
80
|
-
data: { source },
|
|
81
|
-
fix(fixer) {
|
|
82
|
-
const parts = [];
|
|
83
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
84
|
-
const item = sorted[i];
|
|
85
|
-
const prevItem = i > 0 ? sorted[i - 1] : null;
|
|
86
|
-
const start = getStartWithComments(item.node, sourceCode);
|
|
87
|
-
const text = sourceCode.text.slice(start, item.node.range[1]);
|
|
88
|
-
if (i > 0) {
|
|
89
|
-
const prevOriginalIndex = group.findIndex((g) => g === prevItem);
|
|
90
|
-
const currOriginalIndex = group.findIndex((g) => g === item);
|
|
91
|
-
if (Math.abs(currOriginalIndex - prevOriginalIndex) === 1) {
|
|
92
|
-
const earlier = group[Math.min(prevOriginalIndex, currOriginalIndex)];
|
|
93
|
-
const later = group[Math.max(prevOriginalIndex, currOriginalIndex)];
|
|
94
|
-
const sep = sourceCode.text.slice(earlier.node.range[1], getStartWithComments(later.node, sourceCode));
|
|
95
|
-
parts.push(sep);
|
|
96
|
-
} else parts.push("\n");
|
|
97
|
-
}
|
|
98
|
-
parts.push(text);
|
|
99
|
-
}
|
|
100
|
-
const rangeStart = getStartWithComments(firstNode, sourceCode);
|
|
101
|
-
const rangeEnd = lastNode.range[1];
|
|
102
|
-
return fixer.replaceTextRange([rangeStart, rangeEnd], parts.join(""));
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
} };
|
|
107
|
-
}
|
|
108
|
-
} }
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
//#endregion
|
|
112
|
-
export { plugin as default };
|