@ai0x0/utils 0.10.0 → 0.12.0

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
@@ -148,7 +148,7 @@ export default [
148
148
  ];
149
149
  ```
150
150
 
151
- Restrictions cover: `TryStatement`, `as` (non-const), `useState`, `useEffect`, `useMemo`, `useCallback`, `memo()`, native `div/span/p/h` tags, hardcoded colors, `fetch` and `new WebSocket()`.
151
+ Restrictions cover: `TryStatement`, `as` (non-const), `useState`, `useEffect`, `useMemo`, `useCallback`, `useMemoizedFn`, `memo()`, native `div/span/p/h` tags, hardcoded colors, `fetch` and `new WebSocket()`.
152
152
 
153
153
  ## Installation
154
154
 
@@ -45,6 +45,11 @@ export const allRestrictions = [
45
45
  selector: "CallExpression[callee.name='useCallback']",
46
46
  message: "禁止 useCallback。React Compiler 会自动记忆化,无需手动优化。",
47
47
  },
48
+ {
49
+ selector: "CallExpression[callee.name='useMemoizedFn']",
50
+ message:
51
+ "禁止 useMemoizedFn。React Compiler 会自动记忆化:函数只抓不变的值时,引用本来就稳。只有「读着会变的值、引用却必须恒定 / 调用时必须读到最新闭包」才用它(进每个列表项或节点的回调、只在挂载时登记一次的订阅回调、冻住的元素属性、延后执行的回调),用时单行 // eslint-disable-next-line no-restricted-syntax 并注明原因。注意:useXxx 里删掉它之后要是一个 hook 都不剩,React Compiler 就不再编译这个函数。",
52
+ },
48
53
  {
49
54
  selector:
50
55
  ":matches(CallExpression[callee.name='memo'], CallExpression[callee.object.name='React'][callee.property.name='memo'])",
@@ -14,6 +14,7 @@ import maxLines from "./max-lines.js";
14
14
  import apiRouteViaNrf from "./api-route-via-nrf.js";
15
15
  import noDirectApiUrl from "./no-direct-api-url.js";
16
16
  import noHardcodedText from "./no-hardcoded-text.js";
17
+ import noAccentBorder from "./no-accent-border.js";
17
18
 
18
19
  const rules = {
19
20
  "require-section-divider": requireSectionDivider,
@@ -29,6 +30,7 @@ const rules = {
29
30
  "api-route-via-nrf": apiRouteViaNrf,
30
31
  "no-direct-api-url": noDirectApiUrl,
31
32
  "no-hardcoded-text": noHardcodedText,
33
+ "no-accent-border": noAccentBorder,
32
34
  };
33
35
 
34
36
  // 这三条只管「特定位置」的代码,不能跟着 recommended 全局开:
@@ -0,0 +1,246 @@
1
+ /**
2
+ * no-accent-border
3
+ * ----------------
4
+ * **悬停 / 聚焦 / 按下的反馈不许描亮边**,走填充底色;也别显式写回带描边的那几个变体。
5
+ *
6
+ * 拦两类:
7
+ *
8
+ * 1. css`` 或 style 对象里,**交互态选择器内**(:hover / :focus / :focus-within /
9
+ * :focus-visible / :active)把 `border` / `border-color` 设成强调色 token
10
+ * (colorPrimary* / colorInfo* / colorSuccess* / colorWarning* / colorError*);
11
+ * 2. JSX 上显式写 `variant="outlined"` / `variant="dashed"` / `type="default"`。
12
+ *
13
+ * **为什么是「交互态」而不是所有亮边**:常态的亮边多半是状态语言 —— 选中的那一格、框选的
14
+ * 那一块、裁剪区、报错块的红框、拖拽的落点。那些是「这一块此刻不一样」的记号,本来就该跳出
15
+ * 版面。而悬停只是「鼠标恰好停在这儿」,给它一圈亮边,一排控件里就总有一个在抢视线。
16
+ *
17
+ * **第 2 类为什么连 `type="default"` 一起拦**:项目一般会用 ConfigProvider 把全站默认变体
18
+ * 设成 filled(antd v6 的 button/input/select 都收 variant)。而显式写 `type="default"`
19
+ * 会走 antd 的 legacy type 映射、解析成 outlined,**把那个全站默认顶掉** —— 写的人以为自己
20
+ * 只是「要一颗普通按钮」,拿到的却是描边的那一档。`variant="outlined"` 同理,只是更直白。
21
+ *
22
+ * **放行**:常态与选中态的任何边框、中性色描边(colorBorder / colorBorderSecondary /
23
+ * colorSplit / colorText*)、`transparent`、`none`,以及交互态里改底色、改阴影、改文字色。
24
+ *
25
+ * **真要在悬停时描亮边**(输入框聚焦、画布里的落点提示这类),就地写一行
26
+ * `eslint-disable-next-line ai0x0/no-accent-border` 并注明为什么。
27
+ */
28
+
29
+ // ==============================================================================
30
+ // 什么算「强调色」
31
+ // ==============================================================================
32
+ // 按 antd 的色名族判,不看具体色值:这几族是主题里用来「抢眼」的那批,而 colorBorder /
33
+ // colorSplit / colorText 那些是结构色。`colorPrimaryBorder` 也在内 —— 它的用途正是描一圈
34
+ // 主色边,恰恰是这条规则要拦的东西。
35
+ const ACCENT_TOKEN =
36
+ /\b(colorPrimary|colorInfo|colorSuccess|colorWarning|colorError|colorLink|colorHighlight)/;
37
+
38
+ // 要检查的属性名。只管颜色那一半:border-width / border-radius / border-style 与这条无关。
39
+ // 驼峰(style 对象)先拆成连字符再比,两种写法共用这一条。
40
+ const BORDER_PROPERTY = /^border(-(top|right|bottom|left))?(-color)?$/;
41
+
42
+ const isBorderProperty = (name) =>
43
+ BORDER_PROPERTY.test(
44
+ String(name)
45
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
46
+ .toLowerCase(),
47
+ );
48
+
49
+ // ==============================================================================
50
+ // css`` 模板:把插值编号后按声明切开
51
+ // ==============================================================================
52
+ // 模板里 `${token.colorPrimary}` 是一个 AST 表达式、不是文本,所以先把它换成一个编号占位符
53
+ // 拼出整段文本,命中某条声明时再顺着编号回去取那个表达式的源码。
54
+ // 占位符取私有区字符:CSS 正文里不会出现它,也不是控制字符。
55
+ const MARK = "";
56
+
57
+ function templateText(node) {
58
+ return node.quasi.quasis
59
+ .map((quasi, index) =>
60
+ index < node.quasi.expressions.length
61
+ ? `${quasi.value.raw}${MARK}${String(index)}${MARK}`
62
+ : quasi.value.raw,
63
+ )
64
+ .join("");
65
+ }
66
+
67
+ /** 一段文本里引用到的插值编号。 */
68
+ function marksIn(text) {
69
+ return [...text.matchAll(new RegExp(`${MARK}(\\d+)${MARK}`, "gu"))].map(
70
+ (match) => Number(match[1]),
71
+ );
72
+ }
73
+
74
+ // 交互态的伪类。`:focus-within` 与 `:focus-visible` 都算:它们是同一件事的不同精度。
75
+ const INTERACTIVE = /:(hover|focus|focus-within|focus-visible|active)\b/;
76
+
77
+ /**
78
+ * 这条声明落在哪几层选择器里。
79
+ *
80
+ * 扫一遍文本、拿 `{}` 当栈:遇到 `{` 把它前面那段(上一个 `}` 或 `;` 之后的部分)当作选择器
81
+ * 压进去,遇到 `}` 弹出。于是任何位置都能问「我现在在哪几层里」——嵌套写法(`&:hover { … }`
82
+ * 套在别的块里)也答得出来。
83
+ */
84
+ function selectorStackAt(text, index) {
85
+ const stack = [];
86
+ let head = 0;
87
+ for (let at = 0; at < index; at += 1) {
88
+ const char = text[at];
89
+ if (char === "{") {
90
+ stack.push(text.slice(head, at));
91
+ head = at + 1;
92
+ } else if (char === "}") {
93
+ stack.pop();
94
+ head = at + 1;
95
+ } else if (char === ";") {
96
+ head = at + 1;
97
+ }
98
+ }
99
+ return stack;
100
+ }
101
+
102
+ function checkCssTemplate(context, node) {
103
+ const text = templateText(node);
104
+ // 逐条声明看:属性名 + 值。嵌套选择器与注释不会被当成声明(它们前面没有 `属性名:`)。
105
+ for (const match of text.matchAll(/([a-zA-Z-]+)\s*:\s*([^;{}]+)/gu)) {
106
+ const [, property, value] = match;
107
+ if (!isBorderProperty(property)) {
108
+ continue;
109
+ }
110
+ // 常态的亮边是状态语言(选中 / 框选 / 报错),放行;只管交互态里的那种。
111
+ if (
112
+ !selectorStackAt(text, match.index).some((one) => INTERACTIVE.test(one))
113
+ ) {
114
+ continue;
115
+ }
116
+ for (const index of marksIn(value)) {
117
+ const expression = node.quasi.expressions[index];
118
+ if (!expression) {
119
+ continue;
120
+ }
121
+ const source = context.sourceCode.getText(expression);
122
+ if (ACCENT_TOKEN.test(source)) {
123
+ // **报在整只模板上,不是那个插值上**:模板内部的 `/* */` 是 CSS 注释、不是 JS 注释节点,
124
+ // 落在里头的报错没法就地 `eslint-disable-next-line`。报在模板这一行,豁免就写在
125
+ // `xxx: css\`` 前面一行 —— 与 no-hardcoded-style 同一个落点(它也这么报)。
126
+ // 代价是同一只模板里的多条违规都指到同一行,所以消息里带上属性名与那个 token。
127
+ context.report({
128
+ node,
129
+ messageId: "accentBorder",
130
+ data: { property: property.trim(), token: source },
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // ==============================================================================
138
+ // style 对象 / createStyles 的对象写法
139
+ // ==============================================================================
140
+
141
+ function checkStyleObject(context, object, interactive = false) {
142
+ for (const prop of object.properties) {
143
+ if (prop.type !== "Property") {
144
+ continue;
145
+ }
146
+ const key =
147
+ prop.key.type === "Identifier"
148
+ ? prop.key.name
149
+ : prop.key.type === "Literal"
150
+ ? String(prop.key.value)
151
+ : "";
152
+ // 嵌套一层(伪类、子选择器)照样要看,并把「进没进交互态」带下去。
153
+ if (prop.value.type === "ObjectExpression") {
154
+ checkStyleObject(
155
+ context,
156
+ prop.value,
157
+ interactive || INTERACTIVE.test(key),
158
+ );
159
+ continue;
160
+ }
161
+ if (!interactive || !key || !isBorderProperty(key)) {
162
+ continue;
163
+ }
164
+ const source = context.sourceCode.getText(prop.value);
165
+ if (ACCENT_TOKEN.test(source)) {
166
+ context.report({
167
+ node: prop.value,
168
+ messageId: "accentBorder",
169
+ data: { property: key, token: source },
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ // ==============================================================================
176
+ // JSX:带描边的那两个变体
177
+ // ==============================================================================
178
+
179
+ function attributeValue(attribute) {
180
+ const value = attribute.value;
181
+ if (!value) {
182
+ return "";
183
+ }
184
+ if (value.type === "Literal") {
185
+ return String(value.value);
186
+ }
187
+ if (
188
+ value.type === "JSXExpressionContainer" &&
189
+ value.expression.type === "Literal"
190
+ ) {
191
+ return String(value.expression.value);
192
+ }
193
+ return "";
194
+ }
195
+
196
+ /** @type {import('eslint').Rule.RuleModule} */
197
+ const rule = {
198
+ meta: {
199
+ type: "suggestion",
200
+ docs: {
201
+ description:
202
+ "边框不许用强调色;悬停 / 选中的反馈走填充底色,别用带描边的按钮变体。",
203
+ },
204
+ schema: [],
205
+ messages: {
206
+ accentBorder:
207
+ "悬停 / 聚焦时把 `{{property}}` 描成强调色({{token}})。一圈亮边的视觉重量远超它该有的分量,而悬停只是「鼠标恰好停在这儿」—— 反馈改走填充底色(token.colorFill / colorFillSecondary,按钮走 buttonSurface 那类配方)。常态与选中态的亮边不受这条管;输入框聚焦这种确实要描边的,就地 disable 并写明理由。",
208
+ outlinedVariant:
209
+ '显式写 `{{attribute}}="{{value}}"` 会把全站的 filled 默认顶掉,拿到的是描边那一档(`type="default"` 走 antd 的 legacy 映射,解析成 outlined)。要一颗普通按钮就什么都别写;次要按钮写 `color="default" variant="filled"`,要强调的用 `variant="solid"`。确实需要描边的(如浅色行里的输入框)就地 disable 并写明理由。',
210
+ },
211
+ },
212
+ create(context) {
213
+ return {
214
+ JSXAttribute(node) {
215
+ const name = node.name.type === "JSXIdentifier" ? node.name.name : "";
216
+ if (name === "style") {
217
+ const expr = node.value?.expression;
218
+ if (expr?.type === "ObjectExpression") {
219
+ checkStyleObject(context, expr);
220
+ }
221
+ return;
222
+ }
223
+ const value = attributeValue(node);
224
+ const outlined =
225
+ (name === "variant" &&
226
+ (value === "outlined" || value === "dashed")) ||
227
+ (name === "type" && value === "default");
228
+ if (outlined) {
229
+ context.report({
230
+ node,
231
+ messageId: "outlinedVariant",
232
+ data: { attribute: name, value },
233
+ });
234
+ }
235
+ },
236
+
237
+ TaggedTemplateExpression(node) {
238
+ if (node.tag.type === "Identifier" && node.tag.name === "css") {
239
+ checkCssTemplate(context, node);
240
+ }
241
+ },
242
+ };
243
+ },
244
+ };
245
+
246
+ export default rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai0x0/utils",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "AI0x0 utils",
5
5
  "keywords": [
6
6
  "ai0x0"