@wsxjs/eslint-plugin-wsx 0.0.10 → 0.0.12
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.md +187 -0
- package/dist/index.js +180 -1
- package/dist/index.mjs +180 -1
- package/package.json +5 -2
- package/src/configs/recommended.ts +2 -0
- package/src/index.ts +2 -0
- package/src/rules/state-requires-initial-value.ts +136 -0
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -2,6 +2,192 @@
|
|
|
2
2
|
|
|
3
3
|
ESLint plugin for WSX Framework - enforces best practices and framework-specific rules for Web Components with JSX.
|
|
4
4
|
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @wsxjs/eslint-plugin-wsx
|
|
9
|
+
# or
|
|
10
|
+
pnpm add -D @wsxjs/eslint-plugin-wsx
|
|
11
|
+
# or
|
|
12
|
+
yarn add -D @wsxjs/eslint-plugin-wsx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
### ESLint 9+ (Flat Config)
|
|
18
|
+
|
|
19
|
+
Create or update `eslint.config.js` (or `eslint.config.mjs`):
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
import js from "@eslint/js";
|
|
23
|
+
import typescript from "@typescript-eslint/eslint-plugin";
|
|
24
|
+
import typescriptParser from "@typescript-eslint/parser";
|
|
25
|
+
import wsxPlugin from "@wsxjs/eslint-plugin-wsx";
|
|
26
|
+
import globals from "globals";
|
|
27
|
+
|
|
28
|
+
export default [
|
|
29
|
+
{
|
|
30
|
+
ignores: ["**/dist/", "**/node_modules/"],
|
|
31
|
+
},
|
|
32
|
+
js.configs.recommended,
|
|
33
|
+
{
|
|
34
|
+
files: ["**/*.{ts,tsx,js,jsx,wsx}"],
|
|
35
|
+
languageOptions: {
|
|
36
|
+
parser: typescriptParser,
|
|
37
|
+
parserOptions: {
|
|
38
|
+
ecmaVersion: "latest",
|
|
39
|
+
sourceType: "module",
|
|
40
|
+
ecmaFeatures: {
|
|
41
|
+
jsx: true,
|
|
42
|
+
},
|
|
43
|
+
jsxPragma: "h",
|
|
44
|
+
jsxFragmentName: "Fragment",
|
|
45
|
+
experimentalDecorators: true, // Required for @state decorator
|
|
46
|
+
extraFileExtensions: [".wsx"],
|
|
47
|
+
},
|
|
48
|
+
globals: {
|
|
49
|
+
...globals.browser,
|
|
50
|
+
...globals.es2021,
|
|
51
|
+
h: "readonly",
|
|
52
|
+
Fragment: "readonly",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
plugins: {
|
|
56
|
+
"@typescript-eslint": typescript,
|
|
57
|
+
wsx: wsxPlugin,
|
|
58
|
+
},
|
|
59
|
+
rules: {
|
|
60
|
+
...typescript.configs.recommended.rules,
|
|
61
|
+
// WSX plugin rules
|
|
62
|
+
"wsx/render-method-required": "error",
|
|
63
|
+
"wsx/no-react-imports": "error",
|
|
64
|
+
"wsx/web-component-naming": "warn",
|
|
65
|
+
"wsx/state-requires-initial-value": "error",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Required Dependencies
|
|
72
|
+
|
|
73
|
+
Make sure you have these peer dependencies installed:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser globals
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Rules
|
|
80
|
+
|
|
81
|
+
### `wsx/render-method-required`
|
|
82
|
+
|
|
83
|
+
**Error level**: `error`
|
|
84
|
+
|
|
85
|
+
Ensures WSX components implement the required `render()` method.
|
|
86
|
+
|
|
87
|
+
**Invalid**:
|
|
88
|
+
```typescript
|
|
89
|
+
class MyComponent extends WebComponent {
|
|
90
|
+
// Missing render() method
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Valid**:
|
|
95
|
+
```typescript
|
|
96
|
+
class MyComponent extends WebComponent {
|
|
97
|
+
render() {
|
|
98
|
+
return <div>Hello</div>;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `wsx/no-react-imports`
|
|
104
|
+
|
|
105
|
+
**Error level**: `error`
|
|
106
|
+
|
|
107
|
+
Prevents React imports in WSX files. WSX uses its own JSX runtime.
|
|
108
|
+
|
|
109
|
+
**Invalid**:
|
|
110
|
+
```typescript
|
|
111
|
+
import React from "react"; // ❌
|
|
112
|
+
import { useState } from "react"; // ❌
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Valid**:
|
|
116
|
+
```typescript
|
|
117
|
+
import { WebComponent, state } from "@wsxjs/wsx-core"; // ✅
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `wsx/web-component-naming`
|
|
121
|
+
|
|
122
|
+
**Error level**: `warn`
|
|
123
|
+
|
|
124
|
+
Enforces proper Web Component tag naming conventions (kebab-case with at least one hyphen).
|
|
125
|
+
|
|
126
|
+
**Invalid**:
|
|
127
|
+
```typescript
|
|
128
|
+
@autoRegister({ tagName: "mycomponent" }) // ❌ Missing hyphen
|
|
129
|
+
@autoRegister({ tagName: "MyComponent" }) // ❌ Not kebab-case
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Valid**:
|
|
133
|
+
```typescript
|
|
134
|
+
@autoRegister({ tagName: "my-component" }) // ✅
|
|
135
|
+
@autoRegister({ tagName: "wsx-button" }) // ✅
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `wsx/state-requires-initial-value`
|
|
139
|
+
|
|
140
|
+
**Error level**: `error`
|
|
141
|
+
|
|
142
|
+
Requires `@state` decorator properties to have initial values. This is mandatory because we need the initial value to determine if it's a primitive or object/array.
|
|
143
|
+
|
|
144
|
+
**Invalid**:
|
|
145
|
+
```typescript
|
|
146
|
+
class MyComponent extends WebComponent {
|
|
147
|
+
@state private maskStrokeColor?: string; // ❌ Missing initial value
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Valid**:
|
|
152
|
+
```typescript
|
|
153
|
+
class MyComponent extends WebComponent {
|
|
154
|
+
@state private maskStrokeColor = ""; // ✅ String
|
|
155
|
+
@state private count = 0; // ✅ Number
|
|
156
|
+
@state private user = { name: "John" }; // ✅ Object
|
|
157
|
+
@state private items = []; // ✅ Array
|
|
158
|
+
@state private optional?: string = undefined; // ✅ Optional with explicit undefined
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Configuration Options
|
|
163
|
+
|
|
164
|
+
### Disable Specific Rules
|
|
165
|
+
|
|
166
|
+
If you need to disable a specific rule:
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
{
|
|
170
|
+
rules: {
|
|
171
|
+
"wsx/web-component-naming": "off", // Disable naming rule
|
|
172
|
+
"wsx/state-requires-initial-value": "warn", // Change to warning
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### File-Specific Rules
|
|
178
|
+
|
|
179
|
+
Apply rules only to `.wsx` files:
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
{
|
|
183
|
+
files: ["**/*.wsx"],
|
|
184
|
+
rules: {
|
|
185
|
+
"wsx/render-method-required": "error",
|
|
186
|
+
"wsx/no-react-imports": "error",
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
5
191
|
## Testing Results
|
|
6
192
|
|
|
7
193
|
✅ **38 tests passed** with **100% code coverage**
|
|
@@ -38,6 +224,7 @@ This plugin now uses industry-standard testing practices:
|
|
|
38
224
|
- 🔍 **render-method-required**: Ensures WSX components implement the required `render()` method
|
|
39
225
|
- 🚫 **no-react-imports**: Prevents React imports in WSX files
|
|
40
226
|
- 🏷️ **web-component-naming**: Enforces proper Web Component tag naming conventions
|
|
227
|
+
- ✅ **state-requires-initial-value**: Requires `@state` decorator properties to have initial values
|
|
41
228
|
|
|
42
229
|
## Framework Integration
|
|
43
230
|
|
package/dist/index.js
CHANGED
|
@@ -184,6 +184,181 @@ var webComponentNaming = {
|
|
|
184
184
|
}
|
|
185
185
|
};
|
|
186
186
|
|
|
187
|
+
// src/rules/no-state-on-html-attributes.ts
|
|
188
|
+
var noStateOnHtmlAttributes = {
|
|
189
|
+
meta: {
|
|
190
|
+
type: "problem",
|
|
191
|
+
docs: {
|
|
192
|
+
description: "disallow @state decorator on HTML attributes",
|
|
193
|
+
category: "Possible Errors",
|
|
194
|
+
recommended: true
|
|
195
|
+
},
|
|
196
|
+
messages: {
|
|
197
|
+
stateOnHtmlAttribute: "@state decorator cannot be used on properties that are HTML attributes. Property '{{propertyName}}' is defined in observedAttributes. HTML attributes should be handled via onAttributeChanged, not @state decorator."
|
|
198
|
+
},
|
|
199
|
+
schema: []
|
|
200
|
+
},
|
|
201
|
+
create(context) {
|
|
202
|
+
const classObservedAttributes = /* @__PURE__ */ new Map();
|
|
203
|
+
const stateImports = /* @__PURE__ */ new Set();
|
|
204
|
+
return {
|
|
205
|
+
// Track imports
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
+
ImportDeclaration(node) {
|
|
208
|
+
if (node.source.type === "StringLiteral" && (node.source.value === "@wsxjs/wsx-core" || node.source.value.endsWith("/wsx-core"))) {
|
|
209
|
+
node.specifiers.forEach((spec) => {
|
|
210
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && spec.imported.name === "state") {
|
|
211
|
+
const localName = spec.local.type === "Identifier" ? spec.local.name : null;
|
|
212
|
+
if (localName) {
|
|
213
|
+
stateImports.add(localName);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
ClassDeclaration(node) {
|
|
221
|
+
const observedAttributes = [];
|
|
222
|
+
for (const member of node.body.body) {
|
|
223
|
+
if ((member.type === "ClassProperty" || member.type === "ClassPrivateProperty") && member.static && member.key.type === "Identifier" && member.key.name === "observedAttributes") {
|
|
224
|
+
if (member.value?.type === "ArrayExpression") {
|
|
225
|
+
observedAttributes.push(
|
|
226
|
+
...member.value.elements.filter((el) => el && el.type === "StringLiteral").map((el) => el.value)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
} else if (member.type === "ClassMethod" && member.static && member.kind === "get" && member.key.type === "Identifier" && member.key.name === "observedAttributes") {
|
|
230
|
+
const returnStmt = member.body.body.find(
|
|
231
|
+
(stmt) => stmt.type === "ReturnStatement"
|
|
232
|
+
);
|
|
233
|
+
if (returnStmt?.argument?.type === "ArrayExpression") {
|
|
234
|
+
observedAttributes.push(
|
|
235
|
+
...returnStmt.argument.elements.filter((el) => el && el.type === "StringLiteral").map((el) => el.value)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (observedAttributes.length > 0) {
|
|
241
|
+
classObservedAttributes.set(node, observedAttributes);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
+
"ClassDeclaration:exit"(node) {
|
|
246
|
+
classObservedAttributes.delete(node);
|
|
247
|
+
},
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
Decorator(node) {
|
|
250
|
+
let isStateDecorator = false;
|
|
251
|
+
if (node.expression.type === "Identifier") {
|
|
252
|
+
const name = node.expression.name;
|
|
253
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
254
|
+
} else if (node.expression.type === "CallExpression") {
|
|
255
|
+
const callee = node.expression.callee;
|
|
256
|
+
if (callee.type === "Identifier") {
|
|
257
|
+
const name = callee.name;
|
|
258
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!isStateDecorator) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let classNode = node.parent;
|
|
265
|
+
while (classNode && classNode.type !== "ClassDeclaration" && classNode.type !== "ClassExpression") {
|
|
266
|
+
classNode = classNode.parent;
|
|
267
|
+
}
|
|
268
|
+
if (!classNode) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const propertyNode = node.parent;
|
|
272
|
+
if (!propertyNode || propertyNode.type !== "ClassProperty" && propertyNode.type !== "ClassPrivateProperty") {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const propertyName = propertyNode.key.type === "Identifier" ? propertyNode.key.name : null;
|
|
276
|
+
if (!propertyName) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const observedAttributes = classObservedAttributes.get(classNode);
|
|
280
|
+
if (!observedAttributes || observedAttributes.length === 0) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const propertyKebabCase = propertyName.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
284
|
+
const propertyLower = propertyName.toLowerCase();
|
|
285
|
+
if (observedAttributes.some(
|
|
286
|
+
(attr) => attr.toLowerCase() === propertyLower || attr.toLowerCase() === propertyKebabCase
|
|
287
|
+
)) {
|
|
288
|
+
context.report({
|
|
289
|
+
node,
|
|
290
|
+
messageId: "stateOnHtmlAttribute",
|
|
291
|
+
data: { propertyName }
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// src/rules/no-state-on-methods.ts
|
|
300
|
+
var noStateOnMethods = {
|
|
301
|
+
meta: {
|
|
302
|
+
type: "problem",
|
|
303
|
+
docs: {
|
|
304
|
+
description: "disallow @state decorator on methods",
|
|
305
|
+
category: "Possible Errors",
|
|
306
|
+
recommended: true
|
|
307
|
+
},
|
|
308
|
+
messages: {
|
|
309
|
+
stateOnMethod: "@state decorator cannot be used on methods. '{{methodName}}' is a method, not a property. @state can only be used on properties with primitive, object, or array values."
|
|
310
|
+
},
|
|
311
|
+
schema: []
|
|
312
|
+
},
|
|
313
|
+
create(context) {
|
|
314
|
+
const stateImports = /* @__PURE__ */ new Set();
|
|
315
|
+
return {
|
|
316
|
+
// Track imports
|
|
317
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
318
|
+
ImportDeclaration(node) {
|
|
319
|
+
if (node.source.type === "StringLiteral" && (node.source.value === "@wsxjs/wsx-core" || node.source.value.endsWith("/wsx-core"))) {
|
|
320
|
+
node.specifiers.forEach((spec) => {
|
|
321
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && spec.imported.name === "state") {
|
|
322
|
+
const localName = spec.local.type === "Identifier" ? spec.local.name : null;
|
|
323
|
+
if (localName) {
|
|
324
|
+
stateImports.add(localName);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
331
|
+
Decorator(node) {
|
|
332
|
+
let isStateDecorator = false;
|
|
333
|
+
if (node.expression.type === "Identifier") {
|
|
334
|
+
const name = node.expression.name;
|
|
335
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
336
|
+
} else if (node.expression.type === "CallExpression") {
|
|
337
|
+
const callee = node.expression.callee;
|
|
338
|
+
if (callee.type === "Identifier") {
|
|
339
|
+
const name = callee.name;
|
|
340
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!isStateDecorator) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const parent = node.parent;
|
|
347
|
+
if (parent && (parent.type === "ClassMethod" || parent.type === "ClassPrivateMethod")) {
|
|
348
|
+
const methodName = parent.key.type === "Identifier" ? parent.key.name : parent.key.type === "StringLiteral" ? parent.key.value : null;
|
|
349
|
+
if (methodName) {
|
|
350
|
+
context.report({
|
|
351
|
+
node,
|
|
352
|
+
messageId: "stateOnMethod",
|
|
353
|
+
data: { methodName }
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
187
362
|
// src/configs/recommended.ts
|
|
188
363
|
var recommendedConfig = {
|
|
189
364
|
parser: "@typescript-eslint/parser",
|
|
@@ -202,6 +377,8 @@ var recommendedConfig = {
|
|
|
202
377
|
"wsx/render-method-required": "error",
|
|
203
378
|
"wsx/no-react-imports": "error",
|
|
204
379
|
"wsx/web-component-naming": "warn",
|
|
380
|
+
"wsx/no-state-on-html-attributes": "error",
|
|
381
|
+
"wsx/no-state-on-methods": "error",
|
|
205
382
|
// TypeScript 规则(推荐)
|
|
206
383
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
207
384
|
"@typescript-eslint/no-explicit-any": "warn",
|
|
@@ -366,7 +543,9 @@ var plugin = {
|
|
|
366
543
|
rules: {
|
|
367
544
|
"render-method-required": renderMethodRequired,
|
|
368
545
|
"no-react-imports": noReactImports,
|
|
369
|
-
"web-component-naming": webComponentNaming
|
|
546
|
+
"web-component-naming": webComponentNaming,
|
|
547
|
+
"no-state-on-html-attributes": noStateOnHtmlAttributes,
|
|
548
|
+
"no-state-on-methods": noStateOnMethods
|
|
370
549
|
},
|
|
371
550
|
// 配置预设
|
|
372
551
|
configs: {
|
package/dist/index.mjs
CHANGED
|
@@ -155,6 +155,181 @@ var webComponentNaming = {
|
|
|
155
155
|
}
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
+
// src/rules/no-state-on-html-attributes.ts
|
|
159
|
+
var noStateOnHtmlAttributes = {
|
|
160
|
+
meta: {
|
|
161
|
+
type: "problem",
|
|
162
|
+
docs: {
|
|
163
|
+
description: "disallow @state decorator on HTML attributes",
|
|
164
|
+
category: "Possible Errors",
|
|
165
|
+
recommended: true
|
|
166
|
+
},
|
|
167
|
+
messages: {
|
|
168
|
+
stateOnHtmlAttribute: "@state decorator cannot be used on properties that are HTML attributes. Property '{{propertyName}}' is defined in observedAttributes. HTML attributes should be handled via onAttributeChanged, not @state decorator."
|
|
169
|
+
},
|
|
170
|
+
schema: []
|
|
171
|
+
},
|
|
172
|
+
create(context) {
|
|
173
|
+
const classObservedAttributes = /* @__PURE__ */ new Map();
|
|
174
|
+
const stateImports = /* @__PURE__ */ new Set();
|
|
175
|
+
return {
|
|
176
|
+
// Track imports
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
ImportDeclaration(node) {
|
|
179
|
+
if (node.source.type === "StringLiteral" && (node.source.value === "@wsxjs/wsx-core" || node.source.value.endsWith("/wsx-core"))) {
|
|
180
|
+
node.specifiers.forEach((spec) => {
|
|
181
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && spec.imported.name === "state") {
|
|
182
|
+
const localName = spec.local.type === "Identifier" ? spec.local.name : null;
|
|
183
|
+
if (localName) {
|
|
184
|
+
stateImports.add(localName);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
ClassDeclaration(node) {
|
|
192
|
+
const observedAttributes = [];
|
|
193
|
+
for (const member of node.body.body) {
|
|
194
|
+
if ((member.type === "ClassProperty" || member.type === "ClassPrivateProperty") && member.static && member.key.type === "Identifier" && member.key.name === "observedAttributes") {
|
|
195
|
+
if (member.value?.type === "ArrayExpression") {
|
|
196
|
+
observedAttributes.push(
|
|
197
|
+
...member.value.elements.filter((el) => el && el.type === "StringLiteral").map((el) => el.value)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
} else if (member.type === "ClassMethod" && member.static && member.kind === "get" && member.key.type === "Identifier" && member.key.name === "observedAttributes") {
|
|
201
|
+
const returnStmt = member.body.body.find(
|
|
202
|
+
(stmt) => stmt.type === "ReturnStatement"
|
|
203
|
+
);
|
|
204
|
+
if (returnStmt?.argument?.type === "ArrayExpression") {
|
|
205
|
+
observedAttributes.push(
|
|
206
|
+
...returnStmt.argument.elements.filter((el) => el && el.type === "StringLiteral").map((el) => el.value)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (observedAttributes.length > 0) {
|
|
212
|
+
classObservedAttributes.set(node, observedAttributes);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
216
|
+
"ClassDeclaration:exit"(node) {
|
|
217
|
+
classObservedAttributes.delete(node);
|
|
218
|
+
},
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
Decorator(node) {
|
|
221
|
+
let isStateDecorator = false;
|
|
222
|
+
if (node.expression.type === "Identifier") {
|
|
223
|
+
const name = node.expression.name;
|
|
224
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
225
|
+
} else if (node.expression.type === "CallExpression") {
|
|
226
|
+
const callee = node.expression.callee;
|
|
227
|
+
if (callee.type === "Identifier") {
|
|
228
|
+
const name = callee.name;
|
|
229
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!isStateDecorator) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let classNode = node.parent;
|
|
236
|
+
while (classNode && classNode.type !== "ClassDeclaration" && classNode.type !== "ClassExpression") {
|
|
237
|
+
classNode = classNode.parent;
|
|
238
|
+
}
|
|
239
|
+
if (!classNode) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const propertyNode = node.parent;
|
|
243
|
+
if (!propertyNode || propertyNode.type !== "ClassProperty" && propertyNode.type !== "ClassPrivateProperty") {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const propertyName = propertyNode.key.type === "Identifier" ? propertyNode.key.name : null;
|
|
247
|
+
if (!propertyName) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const observedAttributes = classObservedAttributes.get(classNode);
|
|
251
|
+
if (!observedAttributes || observedAttributes.length === 0) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const propertyKebabCase = propertyName.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
255
|
+
const propertyLower = propertyName.toLowerCase();
|
|
256
|
+
if (observedAttributes.some(
|
|
257
|
+
(attr) => attr.toLowerCase() === propertyLower || attr.toLowerCase() === propertyKebabCase
|
|
258
|
+
)) {
|
|
259
|
+
context.report({
|
|
260
|
+
node,
|
|
261
|
+
messageId: "stateOnHtmlAttribute",
|
|
262
|
+
data: { propertyName }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// src/rules/no-state-on-methods.ts
|
|
271
|
+
var noStateOnMethods = {
|
|
272
|
+
meta: {
|
|
273
|
+
type: "problem",
|
|
274
|
+
docs: {
|
|
275
|
+
description: "disallow @state decorator on methods",
|
|
276
|
+
category: "Possible Errors",
|
|
277
|
+
recommended: true
|
|
278
|
+
},
|
|
279
|
+
messages: {
|
|
280
|
+
stateOnMethod: "@state decorator cannot be used on methods. '{{methodName}}' is a method, not a property. @state can only be used on properties with primitive, object, or array values."
|
|
281
|
+
},
|
|
282
|
+
schema: []
|
|
283
|
+
},
|
|
284
|
+
create(context) {
|
|
285
|
+
const stateImports = /* @__PURE__ */ new Set();
|
|
286
|
+
return {
|
|
287
|
+
// Track imports
|
|
288
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
289
|
+
ImportDeclaration(node) {
|
|
290
|
+
if (node.source.type === "StringLiteral" && (node.source.value === "@wsxjs/wsx-core" || node.source.value.endsWith("/wsx-core"))) {
|
|
291
|
+
node.specifiers.forEach((spec) => {
|
|
292
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && spec.imported.name === "state") {
|
|
293
|
+
const localName = spec.local.type === "Identifier" ? spec.local.name : null;
|
|
294
|
+
if (localName) {
|
|
295
|
+
stateImports.add(localName);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
302
|
+
Decorator(node) {
|
|
303
|
+
let isStateDecorator = false;
|
|
304
|
+
if (node.expression.type === "Identifier") {
|
|
305
|
+
const name = node.expression.name;
|
|
306
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
307
|
+
} else if (node.expression.type === "CallExpression") {
|
|
308
|
+
const callee = node.expression.callee;
|
|
309
|
+
if (callee.type === "Identifier") {
|
|
310
|
+
const name = callee.name;
|
|
311
|
+
isStateDecorator = name === "state" || stateImports.has(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!isStateDecorator) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const parent = node.parent;
|
|
318
|
+
if (parent && (parent.type === "ClassMethod" || parent.type === "ClassPrivateMethod")) {
|
|
319
|
+
const methodName = parent.key.type === "Identifier" ? parent.key.name : parent.key.type === "StringLiteral" ? parent.key.value : null;
|
|
320
|
+
if (methodName) {
|
|
321
|
+
context.report({
|
|
322
|
+
node,
|
|
323
|
+
messageId: "stateOnMethod",
|
|
324
|
+
data: { methodName }
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
158
333
|
// src/configs/recommended.ts
|
|
159
334
|
var recommendedConfig = {
|
|
160
335
|
parser: "@typescript-eslint/parser",
|
|
@@ -173,6 +348,8 @@ var recommendedConfig = {
|
|
|
173
348
|
"wsx/render-method-required": "error",
|
|
174
349
|
"wsx/no-react-imports": "error",
|
|
175
350
|
"wsx/web-component-naming": "warn",
|
|
351
|
+
"wsx/no-state-on-html-attributes": "error",
|
|
352
|
+
"wsx/no-state-on-methods": "error",
|
|
176
353
|
// TypeScript 规则(推荐)
|
|
177
354
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
178
355
|
"@typescript-eslint/no-explicit-any": "warn",
|
|
@@ -337,7 +514,9 @@ var plugin = {
|
|
|
337
514
|
rules: {
|
|
338
515
|
"render-method-required": renderMethodRequired,
|
|
339
516
|
"no-react-imports": noReactImports,
|
|
340
|
-
"web-component-naming": webComponentNaming
|
|
517
|
+
"web-component-naming": webComponentNaming,
|
|
518
|
+
"no-state-on-html-attributes": noStateOnHtmlAttributes,
|
|
519
|
+
"no-state-on-methods": noStateOnMethods
|
|
341
520
|
},
|
|
342
521
|
// 配置预设
|
|
343
522
|
configs: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wsxjs/eslint-plugin-wsx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "ESLint plugin for WSX Framework",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"web-components"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@wsxjs/wsx-core": "0.0.
|
|
28
|
+
"@wsxjs/wsx-core": "0.0.12"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"tsup": "^8.0.0",
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"eslint": ">=8.0.0 || ^9.0.0"
|
|
45
45
|
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
46
49
|
"scripts": {
|
|
47
50
|
"build": "tsup src/index.ts --format cjs,esm --dts --cjs-interop",
|
|
48
51
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
@@ -16,6 +16,7 @@ export const recommendedConfig: WSXConfig = {
|
|
|
16
16
|
},
|
|
17
17
|
jsxPragma: "h",
|
|
18
18
|
jsxFragmentName: "Fragment",
|
|
19
|
+
experimentalDecorators: true, // Required to parse @state decorators
|
|
19
20
|
},
|
|
20
21
|
plugins: ["wsx"],
|
|
21
22
|
rules: {
|
|
@@ -23,6 +24,7 @@ export const recommendedConfig: WSXConfig = {
|
|
|
23
24
|
"wsx/render-method-required": "error",
|
|
24
25
|
"wsx/no-react-imports": "error",
|
|
25
26
|
"wsx/web-component-naming": "warn",
|
|
27
|
+
"wsx/state-requires-initial-value": "error",
|
|
26
28
|
|
|
27
29
|
// TypeScript 规则(推荐)
|
|
28
30
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { renderMethodRequired } from "./rules/render-method-required";
|
|
9
9
|
import { noReactImports } from "./rules/no-react-imports";
|
|
10
10
|
import { webComponentNaming } from "./rules/web-component-naming";
|
|
11
|
+
import { stateRequiresInitialValue } from "./rules/state-requires-initial-value";
|
|
11
12
|
import { recommendedConfig } from "./configs/recommended";
|
|
12
13
|
import { createFlatConfig } from "./configs/flat";
|
|
13
14
|
import { WSXPlugin } from "./types";
|
|
@@ -24,6 +25,7 @@ const plugin: WSXPlugin = {
|
|
|
24
25
|
"render-method-required": renderMethodRequired,
|
|
25
26
|
"no-react-imports": noReactImports,
|
|
26
27
|
"web-component-naming": webComponentNaming,
|
|
28
|
+
"state-requires-initial-value": stateRequiresInitialValue,
|
|
27
29
|
},
|
|
28
30
|
|
|
29
31
|
// 配置预设
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint 规则:state-requires-initial-value
|
|
3
|
+
*
|
|
4
|
+
* 确保 @state 装饰器的属性必须有初始值
|
|
5
|
+
* 这是强制性的,因为我们需要初始值来判断是 primitive 还是 object/array
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Rule } from "eslint";
|
|
9
|
+
import { WSXRuleModule } from "../types";
|
|
10
|
+
|
|
11
|
+
export const stateRequiresInitialValue: WSXRuleModule = {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description: "require @state decorator properties to have initial values",
|
|
16
|
+
category: "Possible Errors",
|
|
17
|
+
recommended: true,
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
missingInitialValue:
|
|
21
|
+
"@state decorator on property '{{propertyName}}' requires an initial value.\n" +
|
|
22
|
+
"\n" +
|
|
23
|
+
"Examples:\n" +
|
|
24
|
+
" @state private {{propertyName}} = ''; // for string\n" +
|
|
25
|
+
" @state private {{propertyName}} = 0; // for number\n" +
|
|
26
|
+
" @state private {{propertyName}} = {}; // for object\n" +
|
|
27
|
+
" @state private {{propertyName}} = []; // for array\n" +
|
|
28
|
+
" @state private {{propertyName}} = undefined; // for optional",
|
|
29
|
+
},
|
|
30
|
+
schema: [],
|
|
31
|
+
},
|
|
32
|
+
create(context: Rule.RuleContext) {
|
|
33
|
+
// Track imported 'state' identifiers from @wsxjs/wsx-core
|
|
34
|
+
const stateImports = new Set<string>();
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
// Track imports to identify @state decorator
|
|
38
|
+
ImportDeclaration(node) {
|
|
39
|
+
if (
|
|
40
|
+
node.source.type === "Literal" &&
|
|
41
|
+
typeof node.source.value === "string" &&
|
|
42
|
+
node.source.value === "@wsxjs/wsx-core"
|
|
43
|
+
) {
|
|
44
|
+
node.specifiers.forEach((specifier) => {
|
|
45
|
+
if (specifier.type === "ImportSpecifier") {
|
|
46
|
+
if (
|
|
47
|
+
specifier.imported.type === "Identifier" &&
|
|
48
|
+
specifier.imported.name === "state"
|
|
49
|
+
) {
|
|
50
|
+
// Track both the imported name and any alias
|
|
51
|
+
const localName =
|
|
52
|
+
specifier.local.type === "Identifier"
|
|
53
|
+
? specifier.local.name
|
|
54
|
+
: null;
|
|
55
|
+
if (localName) {
|
|
56
|
+
stateImports.add(localName);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else if (specifier.type === "ImportDefaultSpecifier") {
|
|
60
|
+
// Handle default import (less common but possible)
|
|
61
|
+
const localName =
|
|
62
|
+
specifier.local.type === "Identifier" ? specifier.local.name : null;
|
|
63
|
+
if (localName) {
|
|
64
|
+
stateImports.add(localName);
|
|
65
|
+
}
|
|
66
|
+
} else if (specifier.type === "ImportNamespaceSpecifier") {
|
|
67
|
+
// Handle namespace import: import * as wsx from '@wsxjs/wsx-core'
|
|
68
|
+
// In this case, @state would be wsx.state, which is harder to detect
|
|
69
|
+
// We'll check for both 'state' and namespace.state patterns
|
|
70
|
+
const localName =
|
|
71
|
+
specifier.local.type === "Identifier" ? specifier.local.name : null;
|
|
72
|
+
if (localName) {
|
|
73
|
+
// For namespace imports, we'd need to check member expressions
|
|
74
|
+
// This is more complex, so we'll also check for plain 'state'
|
|
75
|
+
stateImports.add("state");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Check class properties for @state decorator
|
|
83
|
+
// Support both ClassProperty (older) and PropertyDefinition (newer TypeScript ESLint)
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
"ClassProperty, ClassPrivateProperty, PropertyDefinition"(node: any) {
|
|
86
|
+
if (!node.decorators || node.decorators.length === 0) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if any decorator is @state
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
const hasStateDecorator = node.decorators.some((decorator: any) => {
|
|
93
|
+
if (decorator.expression.type === "Identifier") {
|
|
94
|
+
// Direct identifier: @state
|
|
95
|
+
return (
|
|
96
|
+
decorator.expression.name === "state" ||
|
|
97
|
+
stateImports.has(decorator.expression.name)
|
|
98
|
+
);
|
|
99
|
+
} else if (decorator.expression.type === "CallExpression") {
|
|
100
|
+
// Call expression: @state()
|
|
101
|
+
if (decorator.expression.callee.type === "Identifier") {
|
|
102
|
+
return (
|
|
103
|
+
decorator.expression.callee.name === "state" ||
|
|
104
|
+
stateImports.has(decorator.expression.callee.name)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
} else if (decorator.expression.type === "MemberExpression") {
|
|
108
|
+
// Member expression: @namespace.state
|
|
109
|
+
if (
|
|
110
|
+
decorator.expression.property.type === "Identifier" &&
|
|
111
|
+
decorator.expression.property.name === "state"
|
|
112
|
+
) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (hasStateDecorator && !node.value) {
|
|
120
|
+
const propertyName =
|
|
121
|
+
node.key.type === "Identifier"
|
|
122
|
+
? node.key.name
|
|
123
|
+
: node.key.type === "PrivateIdentifier"
|
|
124
|
+
? node.key.name
|
|
125
|
+
: "unknown";
|
|
126
|
+
|
|
127
|
+
context.report({
|
|
128
|
+
node,
|
|
129
|
+
messageId: "missingInitialValue",
|
|
130
|
+
data: { propertyName },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|