@ai0x0/utils 0.0.34 → 0.0.36

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 CHANGED
@@ -63,19 +63,19 @@ export default [
63
63
  ];
64
64
  ```
65
65
 
66
- | Rule | Description |
67
- | ------------------------- | --------------------------------------- |
68
- | `no-hardcoded-style` | Forbid hardcoded style values |
69
- | `no-one-letter-vars` | Forbid single-letter variable names |
70
- | `no-then` | Prefer `async/await` over `.then()` |
71
- | `no-antd-space` | Use `<Flex>` instead of `<Space>` |
72
- | `require-pro-components` | Require ProComponents usage |
73
- | `require-section-divider` | Large files need `===` comment dividers |
74
- | `no-consecutive-setstate` | Merge consecutive `setState` calls |
75
- | `no-use-request-run` | Forbid `useRequest.run()` |
76
- | `require-use-form` | Use `useForm()` for form state |
77
- | `require-form-convention` | Form naming conventions |
78
- | `max-lines` | Limit each file to 500 code lines |
66
+ | Rule | Description |
67
+ | ------------------------- | ----------------------------------------------------------------------- |
68
+ | `no-hardcoded-style` | Forbid hardcoded style values |
69
+ | `no-one-letter-vars` | Forbid single-letter variable names |
70
+ | `no-then` | Prefer `async/await` over `.then()` |
71
+ | `no-antd-space` | Use `<Flex>` instead of `<Space>` |
72
+ | `require-pro-components` | Require ProComponents usage |
73
+ | `require-section-divider` | Large files need `===` comment dividers |
74
+ | `no-consecutive-setstate` | Merge consecutive `setState` calls |
75
+ | `no-use-request-run` | Forbid `useRequest.run()` |
76
+ | `require-use-form` | Use `useForm()` for form state |
77
+ | `require-form-convention` | Form naming conventions |
78
+ | `max-lines` | Limit each file to 500 code lines and prompt splitting into child files |
79
79
 
80
80
  ### Shared restrictions (`eslint-config/`)
81
81
 
@@ -11,6 +11,7 @@ import noUseRequestRun from "./no-use-request-run.js";
11
11
  import noThen from "./no-then.js";
12
12
  import noOneLetterVars from "./no-one-letter-vars.js";
13
13
  import noConsecutiveSetState from "./no-consecutive-setstate.js";
14
+ import maxLines from "./max-lines.js";
14
15
 
15
16
  const rules = {
16
17
  "require-section-divider": requireSectionDivider,
@@ -23,6 +24,7 @@ const rules = {
23
24
  "no-then": noThen,
24
25
  "no-one-letter-vars": noOneLetterVars,
25
26
  "no-consecutive-setstate": noConsecutiveSetState,
27
+ "max-lines": maxLines,
26
28
  };
27
29
 
28
30
  const plugin = {
@@ -35,7 +37,7 @@ const plugin = {
35
37
  Object.keys(rules).map((name) => [`ai0x0/${name}`, "error"]),
36
38
  ),
37
39
  curly: "error",
38
- "max-lines": [
40
+ "ai0x0/max-lines": [
39
41
  "error",
40
42
  { max: 500, skipBlankLines: true, skipComments: true },
41
43
  ],
@@ -0,0 +1,117 @@
1
+ const DEFAULT_MAX = 500;
2
+
3
+ function normalizeOptions(options = {}) {
4
+ if (typeof options === "number") {
5
+ return {
6
+ max: options,
7
+ skipBlankLines: true,
8
+ skipComments: true,
9
+ };
10
+ }
11
+ return {
12
+ max: options.max ?? DEFAULT_MAX,
13
+ skipBlankLines: options.skipBlankLines ?? true,
14
+ skipComments: options.skipComments ?? true,
15
+ };
16
+ }
17
+
18
+ function getCommentLines(sourceCode) {
19
+ const lines = new Set();
20
+ for (const comment of sourceCode.getAllComments()) {
21
+ const start = comment.loc.start.line;
22
+ const end = comment.loc.end.line;
23
+ for (let line = start; line <= end; line++) {
24
+ lines.add(line);
25
+ }
26
+ }
27
+ return lines;
28
+ }
29
+
30
+ /** @type {import('eslint').Rule.RuleModule} */
31
+ const rule = {
32
+ meta: {
33
+ type: "layout",
34
+ docs: {
35
+ description: "限制单文件代码行数,超过时提示拆分子文件。",
36
+ },
37
+ schema: [
38
+ {
39
+ oneOf: [
40
+ {
41
+ type: "integer",
42
+ minimum: 0,
43
+ },
44
+ {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ properties: {
48
+ max: {
49
+ type: "integer",
50
+ minimum: 0,
51
+ },
52
+ skipBlankLines: {
53
+ type: "boolean",
54
+ },
55
+ skipComments: {
56
+ type: "boolean",
57
+ },
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ ],
63
+ messages: {
64
+ tooManyLines:
65
+ "单文件代码行数过多({{ lineCount }} 行,最多 {{ max }} 行),请拆分为子文件。",
66
+ },
67
+ },
68
+ create(context) {
69
+ const options = normalizeOptions(context.options[0]);
70
+
71
+ return {
72
+ "Program:exit"(node) {
73
+ const sourceCode = context.getSourceCode();
74
+ const commentLines = options.skipComments
75
+ ? getCommentLines(sourceCode)
76
+ : new Set();
77
+ let lineCount = 0;
78
+ let reportLine = sourceCode.lines.length || 1;
79
+
80
+ for (let index = 0; index < sourceCode.lines.length; index++) {
81
+ const lineNumber = index + 1;
82
+ const line = sourceCode.lines[index];
83
+ if (options.skipBlankLines && line.trim() === "") {
84
+ continue;
85
+ }
86
+ if (options.skipComments && commentLines.has(lineNumber)) {
87
+ continue;
88
+ }
89
+
90
+ lineCount++;
91
+ if (lineCount === options.max + 1) {
92
+ reportLine = lineNumber;
93
+ }
94
+ }
95
+
96
+ if (lineCount <= options.max) {
97
+ return;
98
+ }
99
+
100
+ context.report({
101
+ node,
102
+ loc: {
103
+ line: reportLine,
104
+ column: 0,
105
+ },
106
+ messageId: "tooManyLines",
107
+ data: {
108
+ lineCount,
109
+ max: options.max,
110
+ },
111
+ });
112
+ },
113
+ };
114
+ },
115
+ };
116
+
117
+ export default rule;
@@ -1,18 +1,3 @@
1
- /**
2
- * no-hardcoded-style
3
- * ------------------
4
- * 禁止在 JSX style / createStyles / css`` 中直接写死样式值。
5
- * 强制使用 antd token(theme.useToken() 或 createStyles(({token}) => ...))。
6
- *
7
- * 白名单:
8
- * - token.xxx / token() / calc(...) 表达式
9
- * - 0 值
10
- * - CSS 功能性关键字(auto, none, hidden, pointer 等)
11
- * - 百分比 / 带单位字符串
12
- * - 条件/逻辑/变量/函数调用(无法静态判断)
13
- * - css\`...\` 中的 \${token.xxx} 插值
14
- */
15
-
16
1
  // ==============================================================================
17
2
  // 白名单
18
3
  // ==============================================================================
@@ -22,6 +7,8 @@ const ALLOWED_LITERALS = new Set([
22
7
  "none",
23
8
  "hidden",
24
9
  "visible",
10
+ "transparent",
11
+ "inherit",
25
12
  "pre-wrap",
26
13
  "pre",
27
14
  "normal",
@@ -160,9 +147,6 @@ const TOKEN_KEYS = new Set([
160
147
  "lineHeight",
161
148
  "letterSpacing",
162
149
  "boxShadow",
163
- "textShadow",
164
- "opacity",
165
- "zIndex",
166
150
  "width",
167
151
  "height",
168
152
  "minWidth",
@@ -207,6 +191,39 @@ function isTokenExpr(node) {
207
191
  return false;
208
192
  }
209
193
 
194
+ function containsTokenExpr(node) {
195
+ if (!node) {
196
+ return false;
197
+ }
198
+ if (isTokenExpr(node)) {
199
+ return true;
200
+ }
201
+ switch (node.type) {
202
+ case "BinaryExpression":
203
+ case "LogicalExpression":
204
+ return containsTokenExpr(node.left) || containsTokenExpr(node.right);
205
+ case "UnaryExpression":
206
+ return containsTokenExpr(node.argument);
207
+ case "ConditionalExpression":
208
+ return [node.test, node.consequent, node.alternate].some((child) =>
209
+ containsTokenExpr(child),
210
+ );
211
+ case "CallExpression":
212
+ return [node.callee, ...node.arguments].some((child) =>
213
+ containsTokenExpr(child),
214
+ );
215
+ case "TemplateLiteral":
216
+ return node.expressions.some((expr) => containsTokenExpr(expr));
217
+ case "MemberExpression":
218
+ return containsTokenExpr(node.object) || containsTokenExpr(node.property);
219
+ default:
220
+ return false;
221
+ }
222
+ }
223
+
224
+ const CSS_GLUE_KEYWORDS =
225
+ /\b(auto|none|hidden|visible|transparent|inherit|currentColor|important|calc|min|max|clamp|var|pre-wrap|pre|normal|pointer|default|move|not-allowed|grab|grabbing|center|flex-start|flex-end|space-between|space-around|left|right|top|bottom|start|end|solid|dashed|dotted|double|groove|ridge|inset|outset|block|inline-block|inline|flex|grid|contents|row|column|row-reverse|column-reverse|wrap|nowrap|wrap-reverse|relative|absolute|fixed|sticky|static|bold|bolder|lighter|italic|oblique|uppercase|lowercase|capitalize|underline|overline|line-through|blink|collapse|separate|border-box|content-box|padding-box|contain|cover|fill|scale-down)\b/g;
226
+
210
227
  function isAllowedValue(node) {
211
228
  if (!node) {
212
229
  return true;
@@ -242,6 +259,7 @@ function isAllowedValue(node) {
242
259
  "TemplateLiteral",
243
260
  "CallExpression",
244
261
  "Identifier",
262
+ "MemberExpression",
245
263
  "SpreadElement",
246
264
  ].includes(node.type)
247
265
  ) {
@@ -253,6 +271,69 @@ function isAllowedValue(node) {
253
271
  return false;
254
272
  }
255
273
 
274
+ function normalizeCssValue(value) {
275
+ return value
276
+ .replace(/\/\*[\s\S]*?\*\//g, "")
277
+ .replace(/\s+/g, " ")
278
+ .trim();
279
+ }
280
+
281
+ function isAllowedCssLiteralValue(value) {
282
+ const normalized = normalizeCssValue(value);
283
+ if (!normalized) {
284
+ return true;
285
+ }
286
+ if (
287
+ normalized === "0" ||
288
+ normalized === "0px" ||
289
+ ALLOWED_LITERALS.has(normalized)
290
+ ) {
291
+ return true;
292
+ }
293
+ if (
294
+ /^\d+\.?\d*%$/.test(normalized) ||
295
+ /^100(vh|vw)$/.test(normalized) ||
296
+ normalized === "1"
297
+ ) {
298
+ return true;
299
+ }
300
+ return false;
301
+ }
302
+
303
+ function stripAllowedExpressionGlue(value) {
304
+ return value
305
+ .replace(/\$\{EXPR_\d+\}/g, " ")
306
+ .replace(/\b100(vh|vw)\b/g, " ")
307
+ .replace(/\b(px|em|rem|%|vh|vw|vmin|vmax|ex|ch|cm|mm|in|pt|pc)\b/g, " ")
308
+ .replace(CSS_GLUE_KEYWORDS, " ")
309
+ .replace(/\b0\b/g, " ")
310
+ .replace(/\b100\b/g, " ")
311
+ .replace(/[(),/+\-%!]/g, " ")
312
+ .replace(/\s+/g, " ")
313
+ .trim();
314
+ }
315
+
316
+ function hasHardcodedCssLiteral(value) {
317
+ const normalized = normalizeCssValue(value);
318
+ if (isAllowedCssLiteralValue(normalized)) {
319
+ return false;
320
+ }
321
+ if (/#[0-9a-fA-F]{3,8}\b/.test(normalized)) {
322
+ return true;
323
+ }
324
+ if (
325
+ /\b(?:rgb|rgba|hsl|hsla|oklch|oklab|linear-gradient|radial-gradient)\(/i.test(
326
+ normalized,
327
+ )
328
+ ) {
329
+ return true;
330
+ }
331
+ if (/\b-?\d*\.?\d+\b/.test(stripAllowedExpressionGlue(normalized))) {
332
+ return true;
333
+ }
334
+ return stripAllowedExpressionGlue(normalized).length > 0;
335
+ }
336
+
256
337
  function checkStyleObject(context, objExpr) {
257
338
  for (const prop of objExpr.properties) {
258
339
  if (prop.type !== "Property") {
@@ -290,7 +371,7 @@ function checkCssTemplate(context, node) {
290
371
  for (let i = 0; i < quasis.length; i++) {
291
372
  fullText += quasis[i].value.raw;
292
373
  if (i < expressions.length) {
293
- fullText += "${EXPR}";
374
+ fullText += `\${EXPR_${i}}`;
294
375
  }
295
376
  }
296
377
 
@@ -327,46 +408,25 @@ function checkCssTemplate(context, node) {
327
408
  continue;
328
409
  }
329
410
 
330
- // 值包含 ${EXPR} → 被插值打断或完全由表达式提供 → 跳过
331
- if (rawVal.includes("${EXPR}")) {
332
- continue;
333
- }
411
+ const expressionIndexes = [...rawVal.matchAll(/\$\{EXPR_(\d+)\}/g)].map(
412
+ (match) => Number(match[1]),
413
+ );
334
414
 
335
- // 0 值
336
- if (rawVal === "0" || rawVal === "0px") {
337
- continue;
338
- }
339
-
340
- // CSS 关键字白名单
341
- if (ALLOWED_LITERALS.has(rawVal)) {
342
- continue;
343
- }
344
-
345
- // 纯百分比 → 允许(布局常用)
346
- if (/^\d+\.?\d*%$/.test(rawVal)) {
347
- continue;
348
- }
349
-
350
- // 100vh / 100vw → 允许(全屏布局)
351
- if (/^100(vh|vw)$/.test(rawVal)) {
415
+ // 完全由非 token 变量 / 函数提供的值无法静态判断,保持兼容。
416
+ if (
417
+ expressionIndexes.length > 0 &&
418
+ expressionIndexes.every(
419
+ (index) => !containsTokenExpr(expressions[index]),
420
+ ) &&
421
+ !hasHardcodedCssLiteral(rawVal)
422
+ ) {
352
423
  continue;
353
424
  }
354
425
 
355
- // 百分比 / 带单位字符串 → 硬编码(token 体系也有 sizeStep/sizeUnit)
356
- if (
357
- /^(\d+(\.\d+)?(px|em|rem|%|vh|vw|vmin|vmax|ex|ch|cm|mm|in|pt|pc)|\d+\.?\d*%)$/.test(
358
- rawVal,
359
- )
360
- ) {
361
- context.report({
362
- node: template,
363
- messageId: "noHardcoded",
364
- data: { key: rawProp, value: rawVal },
365
- });
426
+ if (!hasHardcodedCssLiteral(rawVal)) {
366
427
  continue;
367
428
  }
368
429
 
369
- // 剩余 → 硬编码
370
430
  context.report({
371
431
  node: template,
372
432
  messageId: "noHardcoded",
@@ -431,11 +491,6 @@ const rule = {
431
491
  }
432
492
  if (prop.value.type === "ObjectExpression") {
433
493
  checkStyleObject(context, prop.value);
434
- } else if (
435
- prop.value.type === "TaggedTemplateExpression" &&
436
- prop.value.tag.name === "css"
437
- ) {
438
- checkCssTemplate(context, prop.value);
439
494
  }
440
495
  }
441
496
  return;
@@ -458,11 +513,6 @@ const rule = {
458
513
  }
459
514
  if (prop.value.type === "ObjectExpression") {
460
515
  checkStyleObject(context, prop.value);
461
- } else if (
462
- prop.value.type === "TaggedTemplateExpression" &&
463
- prop.value.tag.name === "css"
464
- ) {
465
- checkCssTemplate(context, prop.value);
466
516
  }
467
517
  }
468
518
  return;
@@ -480,11 +530,6 @@ const rule = {
480
530
  }
481
531
  if (prop.value.type === "ObjectExpression") {
482
532
  checkStyleObject(context, prop.value);
483
- } else if (
484
- prop.value.type === "TaggedTemplateExpression" &&
485
- prop.value.tag.name === "css"
486
- ) {
487
- checkCssTemplate(context, prop.value);
488
533
  }
489
534
  }
490
535
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai0x0/utils",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "description": "AI0x0 utils",
5
5
  "keywords": [
6
6
  "ai0x0"