@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 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
- - 🎯 **type-before-value rule** — Custom oxlint JS plugin ensuring `import type` comes after `import` for the same module
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 Rule: import-type-order
112
+ #### Custom Rules: soybeanjs-import
113
113
 
114
- A custom oxlint JS plugin that ensures `import type` always comes after `import` for the same module:
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-type-order'],
159
+ jsPlugins: ['@soybeanjs/oxc-config/plugins/import'],
134
160
  rules: {
135
- 'import-type-order/type-after-value': 'warn'
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
- - 🎯 **type-before-value 规则** — 自定义 oxlint JS 插件,确保同模块 `import type` 在 `import` 之后
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-type-order
112
+ #### 自定义规则:soybeanjs-import
113
113
 
114
- 本配置附带一个 oxlint JS 插件,确保同一模块的 `import type` 始终在 `import` 之后:
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-type-order'],
161
+ jsPlugins: ['@soybeanjs/oxc-config/plugins/import'],
136
162
  rules: {
137
- 'import-type-order/type-after-value': 'warn'
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-type-order"],
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
- "no-unused-vars": "off",
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.0.5",
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
- "./import-type-order": {
39
- "import": "./dist/import-type-order.js",
40
- "require": "./dist/import-type-order.js"
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.0",
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 };