@af-mobile/eslint-plugin 2.0.4 → 2.2.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/index.js +111 -95
- package/package.json +35 -31
- package/rules/atomic-duplicate.js +68 -60
- package/rules/k-for-require-key.js +33 -0
- package/rules/k-no-bare-and.js +31 -0
- package/rules/k-no-object-interpolation.js +31 -0
- package/rules/no-arbitrary-value.js +52 -52
- package/rules/no-emoji-icon.js +69 -0
- package/rules/no-inline-style.js +129 -127
- package/rules/no-recipe-break.js +59 -59
- package/rules/no-register-all.js +31 -31
- package/rules/no-tailwind-syntax.js +31 -31
- package/rules/no-token-modification.js +92 -92
- package/rules/no-variant-conflict.js +83 -72
- package/rules/prefer-component.js +58 -58
- package/rules/token-whitelist.js +84 -84
- package/rules/wc-aria-required.js +60 -60
- package/rules/wc-block-no-internal-ref.js +60 -60
- package/rules/wc-block-props-count.js +48 -48
- package/rules/wc-block-states.js +64 -64
- package/rules/wc-block-variant-enum.js +83 -83
- package/rules/wc-cleanup.js +75 -75
- package/rules/wc-event-naming.js +60 -60
- package/rules/wc-light-no-style.js +87 -87
- package/rules/wc-part-naming.js +33 -33
- package/rules/wc-shadow-use-token.js +72 -72
- package/utils/aria-requirements.json +101 -101
- package/utils/helpers.js +106 -84
- package/utils/whitelist-v1.json +41 -1
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
// L1-1 af-mobile/no-token-modification(error)
|
|
2
|
-
// 检测:非 tokens.css / tokens.project.css 文件内,重定义 --(c|s|r|t|lh|fw|shadow|z|ease|dur)-* 变量
|
|
3
|
-
// 例外:路径匹配 **/tokens.css 或 **/tokens.project.css
|
|
4
|
-
import postcss from 'postcss';
|
|
5
|
-
|
|
6
|
-
const TOKEN_PREFIX_RE = /^--(c|s|r|t|lh|fw|shadow|z|ease|dur)-/;
|
|
7
|
-
|
|
8
|
-
export default {
|
|
9
|
-
meta: {
|
|
10
|
-
type: 'problem',
|
|
11
|
-
docs: {
|
|
12
|
-
description: '禁止在 tokens.css 之外修改/重定义设计 token 变量',
|
|
13
|
-
},
|
|
14
|
-
schema: [],
|
|
15
|
-
messages: {
|
|
16
|
-
locked: "Token variable '{{name}}' is locked. Modify tokens.css or register in tokens.project.css instead",
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
create(context) {
|
|
21
|
-
const filename = context.filename || context.getFilename();
|
|
22
|
-
// 例外文件:tokens.css 和 tokens.project.css
|
|
23
|
-
if (/tokens(\.project)?\.css$/.test(filename)) return {};
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
// ESLint 9 对 .css 文件原生支持不足,用源码文本直接喂 postcss
|
|
27
|
-
Program(node) {
|
|
28
|
-
const source = context.sourceCode || context.getSourceCode();
|
|
29
|
-
const css = source.getText();
|
|
30
|
-
if (!css.includes('--')) return;
|
|
31
|
-
try {
|
|
32
|
-
const root = postcss.parse(css);
|
|
33
|
-
root.walkDecls(decl => {
|
|
34
|
-
if (TOKEN_PREFIX_RE.test(decl.prop)) {
|
|
35
|
-
context.report({
|
|
36
|
-
node,
|
|
37
|
-
loc: {
|
|
38
|
-
// postcss 节点有 source.start/end(行从 1 起,列从 0 起 → ESLint 期望 0 起)
|
|
39
|
-
start: { line: decl.source.start.line, column: decl.source.start.column - 1 },
|
|
40
|
-
end: { line: decl.source.end.line, column: decl.source.end.column },
|
|
41
|
-
},
|
|
42
|
-
messageId: 'locked',
|
|
43
|
-
data: { name: decl.prop },
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
} catch {
|
|
48
|
-
// 非 CSS 内容(如 .js 文件恰好进入此规则):静默跳过
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
// 检测 JS 内 el.style.setProperty('--c-brand', ...) / removeProperty('--c-brand') 间接覆盖 token
|
|
52
|
-
// 也覆盖 el.style['--c-brand'] = ... 形式
|
|
53
|
-
CallExpression(node) {
|
|
54
|
-
const callee = node.callee;
|
|
55
|
-
if (callee?.type !== 'MemberExpression') return;
|
|
56
|
-
if (callee.object?.type !== 'MemberExpression') return;
|
|
57
|
-
if (callee.object.property?.name !== 'style') return;
|
|
58
|
-
const method = callee.property?.name;
|
|
59
|
-
if (method !== 'setProperty' && method !== 'removeProperty') return;
|
|
60
|
-
const propArg = node.arguments?.[0];
|
|
61
|
-
if (!propArg || propArg.type !== 'Literal' || typeof propArg.value !== 'string') return;
|
|
62
|
-
if (TOKEN_PREFIX_RE.test(propArg.value)) {
|
|
63
|
-
context.report({ node, messageId: 'locked', data: { name: propArg.value } });
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
AssignmentExpression(node) {
|
|
67
|
-
// el.style['--c-brand'] = ... 或 el.style.cssText = '--c-brand: ...'
|
|
68
|
-
if (node.left?.type !== 'MemberExpression') return;
|
|
69
|
-
if (node.left.object?.type !== 'MemberExpression') return;
|
|
70
|
-
if (node.left.object.property?.name !== 'style') return;
|
|
71
|
-
// 计算属性访问 el.style['--c-brand']
|
|
72
|
-
const propNode = node.left.property;
|
|
73
|
-
if (propNode?.type === 'Literal' && typeof propNode.value === 'string'
|
|
74
|
-
&& TOKEN_PREFIX_RE.test(propNode.value)) {
|
|
75
|
-
context.report({ node, messageId: 'locked', data: { name: propNode.value } });
|
|
76
|
-
}
|
|
77
|
-
// el.style.cssText = '--c-brand: red; ...' 间接覆盖
|
|
78
|
-
if (propNode?.type === 'Identifier' && propNode.name === 'cssText') {
|
|
79
|
-
const valNode = node.right;
|
|
80
|
-
if (valNode?.type === 'Literal' && typeof valNode.value === 'string') {
|
|
81
|
-
const matches = valNode.value.match(/--[a-z][\w-]*/g) || [];
|
|
82
|
-
for (const m of matches) {
|
|
83
|
-
if (TOKEN_PREFIX_RE.test(m)) {
|
|
84
|
-
context.report({ node, messageId: 'locked', data: { name: m } });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
},
|
|
92
|
-
};
|
|
1
|
+
// L1-1 af-mobile/no-token-modification(error)
|
|
2
|
+
// 检测:非 tokens.css / tokens.project.css 文件内,重定义 --(c|s|r|t|lh|fw|shadow|z|ease|dur)-* 变量
|
|
3
|
+
// 例外:路径匹配 **/tokens.css 或 **/tokens.project.css
|
|
4
|
+
import postcss from 'postcss';
|
|
5
|
+
|
|
6
|
+
const TOKEN_PREFIX_RE = /^--(c|s|r|t|lh|fw|shadow|z|ease|dur)-/;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
docs: {
|
|
12
|
+
description: '禁止在 tokens.css 之外修改/重定义设计 token 变量',
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
locked: "Token variable '{{name}}' is locked. Modify tokens.css or register in tokens.project.css instead",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
const filename = context.filename || context.getFilename();
|
|
22
|
+
// 例外文件:tokens.css 和 tokens.project.css
|
|
23
|
+
if (/tokens(\.project)?\.css$/.test(filename)) return {};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
// ESLint 9 对 .css 文件原生支持不足,用源码文本直接喂 postcss
|
|
27
|
+
Program(node) {
|
|
28
|
+
const source = context.sourceCode || context.getSourceCode();
|
|
29
|
+
const css = source.getText();
|
|
30
|
+
if (!css.includes('--')) return;
|
|
31
|
+
try {
|
|
32
|
+
const root = postcss.parse(css);
|
|
33
|
+
root.walkDecls(decl => {
|
|
34
|
+
if (TOKEN_PREFIX_RE.test(decl.prop)) {
|
|
35
|
+
context.report({
|
|
36
|
+
node,
|
|
37
|
+
loc: {
|
|
38
|
+
// postcss 节点有 source.start/end(行从 1 起,列从 0 起 → ESLint 期望 0 起)
|
|
39
|
+
start: { line: decl.source.start.line, column: decl.source.start.column - 1 },
|
|
40
|
+
end: { line: decl.source.end.line, column: decl.source.end.column },
|
|
41
|
+
},
|
|
42
|
+
messageId: 'locked',
|
|
43
|
+
data: { name: decl.prop },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// 非 CSS 内容(如 .js 文件恰好进入此规则):静默跳过
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
// 检测 JS 内 el.style.setProperty('--c-brand', ...) / removeProperty('--c-brand') 间接覆盖 token
|
|
52
|
+
// 也覆盖 el.style['--c-brand'] = ... 形式
|
|
53
|
+
CallExpression(node) {
|
|
54
|
+
const callee = node.callee;
|
|
55
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
56
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
57
|
+
if (callee.object.property?.name !== 'style') return;
|
|
58
|
+
const method = callee.property?.name;
|
|
59
|
+
if (method !== 'setProperty' && method !== 'removeProperty') return;
|
|
60
|
+
const propArg = node.arguments?.[0];
|
|
61
|
+
if (!propArg || propArg.type !== 'Literal' || typeof propArg.value !== 'string') return;
|
|
62
|
+
if (TOKEN_PREFIX_RE.test(propArg.value)) {
|
|
63
|
+
context.report({ node, messageId: 'locked', data: { name: propArg.value } });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
AssignmentExpression(node) {
|
|
67
|
+
// el.style['--c-brand'] = ... 或 el.style.cssText = '--c-brand: ...'
|
|
68
|
+
if (node.left?.type !== 'MemberExpression') return;
|
|
69
|
+
if (node.left.object?.type !== 'MemberExpression') return;
|
|
70
|
+
if (node.left.object.property?.name !== 'style') return;
|
|
71
|
+
// 计算属性访问 el.style['--c-brand']
|
|
72
|
+
const propNode = node.left.property;
|
|
73
|
+
if (propNode?.type === 'Literal' && typeof propNode.value === 'string'
|
|
74
|
+
&& TOKEN_PREFIX_RE.test(propNode.value)) {
|
|
75
|
+
context.report({ node, messageId: 'locked', data: { name: propNode.value } });
|
|
76
|
+
}
|
|
77
|
+
// el.style.cssText = '--c-brand: red; ...' 间接覆盖
|
|
78
|
+
if (propNode?.type === 'Identifier' && propNode.name === 'cssText') {
|
|
79
|
+
const valNode = node.right;
|
|
80
|
+
if (valNode?.type === 'Literal' && typeof valNode.value === 'string') {
|
|
81
|
+
const matches = valNode.value.match(/--[a-z][\w-]*/g) || [];
|
|
82
|
+
for (const m of matches) {
|
|
83
|
+
if (TOKEN_PREFIX_RE.test(m)) {
|
|
84
|
+
context.report({ node, messageId: 'locked', data: { name: m } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -1,72 +1,83 @@
|
|
|
1
|
-
// L2-3 af-mobile/no-variant-conflict(warn,可自动修)
|
|
2
|
-
// 检测:同属性互斥变体对同时出现,自动修:保留最后一个
|
|
3
|
-
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
-
|
|
5
|
-
// 互斥变体组:同组内只能出现一个
|
|
6
|
-
const CONFLICT_GROUPS = [
|
|
7
|
-
['btn-sm', 'btn-lg'],
|
|
8
|
-
['tag-ok', 'tag-warn', 'tag-danger'],
|
|
9
|
-
//
|
|
10
|
-
['
|
|
11
|
-
//
|
|
12
|
-
['
|
|
13
|
-
//
|
|
14
|
-
['
|
|
15
|
-
//
|
|
16
|
-
['
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
// L2-3 af-mobile/no-variant-conflict(warn,可自动修)
|
|
2
|
+
// 检测:同属性互斥变体对同时出现,自动修:保留最后一个
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
// 互斥变体组:同组内只能出现一个
|
|
6
|
+
const CONFLICT_GROUPS = [
|
|
7
|
+
['btn-sm', 'btn-lg'],
|
|
8
|
+
['tag-ok', 'tag-warn', 'tag-danger'],
|
|
9
|
+
// 描边式语义色标签(v1.6.1 新增,同 tag 语义色互斥规则)
|
|
10
|
+
['tag-plain-ok', 'tag-plain-warn', 'tag-plain-danger'],
|
|
11
|
+
// 圆角类
|
|
12
|
+
['r-s', 'r-m', 'r-l', 'r-f'],
|
|
13
|
+
// padding 类
|
|
14
|
+
['p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-7', 'p-8', 'p-10'],
|
|
15
|
+
// 定向 padding 类(v1.6.1 新增,同为 padding-left/right 或 padding-top/bottom)
|
|
16
|
+
['px-1', 'px-2', 'px-3', 'px-4'],
|
|
17
|
+
['py-1', 'py-2', 'py-3', 'py-4'],
|
|
18
|
+
// display 类(同为 display 属性)
|
|
19
|
+
['f', 'fi', 'fc'],
|
|
20
|
+
// 网格类(v1.6.1 新增,同为 display:grid,grid-2/3 含列定义)
|
|
21
|
+
['grid', 'grid-2', 'grid-3'],
|
|
22
|
+
// 比例类(v1.6.1 新增,同为 aspect-ratio)
|
|
23
|
+
['aspect-1', 'aspect-4-3', 'aspect-16-9'],
|
|
24
|
+
// 行高类
|
|
25
|
+
['lh-tight', 'lh-normal'],
|
|
26
|
+
// 背景类(v1.6.1 新增渐变/彩色底并入同组:同为 background 属性)
|
|
27
|
+
['bg-brand', 'bg-card', 'bg-muted', 'bg-brand-soft', 'bg-danger', 'bg-success', 'bg-grad-brand', 'bg-grad-brand-soft'],
|
|
28
|
+
// 边框类(v1.6.1 新增,同为 border)
|
|
29
|
+
['border', 'border-brand'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
meta: {
|
|
34
|
+
type: 'suggestion',
|
|
35
|
+
docs: { description: '检测互斥变体冲突,保留最后一个' },
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
conflict: "Variant conflict: '{{a}}' and '{{b}}' — only the later one takes effect, remove the earlier one",
|
|
39
|
+
},
|
|
40
|
+
fixable: 'code',
|
|
41
|
+
},
|
|
42
|
+
create(context) {
|
|
43
|
+
function checkClasses(classes, node, raw, offset) {
|
|
44
|
+
for (const group of CONFLICT_GROUPS) {
|
|
45
|
+
const found = [];
|
|
46
|
+
for (const cls of classes) {
|
|
47
|
+
if (group.includes(cls)) found.push(cls);
|
|
48
|
+
}
|
|
49
|
+
if (found.length < 2) continue;
|
|
50
|
+
// 保留最后一个,删除前面的
|
|
51
|
+
const keep = found[found.length - 1];
|
|
52
|
+
const remove = found.slice(0, -1);
|
|
53
|
+
for (const r of remove) {
|
|
54
|
+
context.report({
|
|
55
|
+
node,
|
|
56
|
+
messageId: 'conflict',
|
|
57
|
+
data: { a: r, b: keep },
|
|
58
|
+
fix(fixer) {
|
|
59
|
+
// 删除 class 名 + 其后的一个空格(保留的类在后面,必有空格可吃)
|
|
60
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
61
|
+
const text = sourceCode.getText(node);
|
|
62
|
+
const idx = text.indexOf(raw);
|
|
63
|
+
if (idx < 0) return null;
|
|
64
|
+
const classIdx = idx + raw.indexOf(r);
|
|
65
|
+
const before = text.slice(0, classIdx);
|
|
66
|
+
const after = text.slice(classIdx + r.length).replace(/^\s+/, '');
|
|
67
|
+
return fixer.replaceText(node, before + after);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function checkString(str, node) {
|
|
74
|
+
for (const { classes, raw, offset } of extractAllClassLists(str)) {
|
|
75
|
+
checkClasses(classes, node, raw, offset);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
80
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
// L2-6 af-mobile/prefer-component(warn)
|
|
2
|
-
// 检测:(a) .toast class + setTimeout / (b) .sheet class vs <af-action-sheet> / (c) .list class + scroll 监听
|
|
3
|
-
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
-
|
|
5
|
-
export default {
|
|
6
|
-
meta: {
|
|
7
|
-
type: 'suggestion',
|
|
8
|
-
docs: { description: '建议使用 L3 组件替代手动实现' },
|
|
9
|
-
schema: [],
|
|
10
|
-
messages: {
|
|
11
|
-
toast: "Manual .toast + setTimeout detected, prefer <af-toast> for singleton queue / auto-dismiss / aria-live",
|
|
12
|
-
sheet: "Manual .sheet class detected, prefer <af-action-sheet> for touch-friendly bottom sheet / gesture dismiss",
|
|
13
|
-
list: "Manual .list + scroll listener detected, prefer <af-list> for virtual scroll / pull refresh / load more",
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
create(context) {
|
|
17
|
-
const filename = context.filename || context.getFilename();
|
|
18
|
-
// 组件源码自身实现不适用此规则(避免 af-list.js 报"prefer <af-list>"的自指误报)
|
|
19
|
-
if (/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
20
|
-
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
21
|
-
let hasToast = false, hasSheet = false, hasList = false;
|
|
22
|
-
let hasSetTimeout = false, hasScrollListener = false;
|
|
23
|
-
|
|
24
|
-
function scan(str) {
|
|
25
|
-
for (const { classes } of extractAllClassLists(str)) {
|
|
26
|
-
if (classes.includes('toast')) hasToast = true;
|
|
27
|
-
if (classes.includes('sheet')) hasSheet = true;
|
|
28
|
-
if (classes.includes('list')) hasList = true;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
Literal(node) { if (typeof node.value === 'string') scan(node.value); },
|
|
34
|
-
TemplateElement(node) { if (node.value?.raw) scan(node.value.raw); },
|
|
35
|
-
CallExpression(node) {
|
|
36
|
-
// setTimeout 检测
|
|
37
|
-
const callee = node.callee;
|
|
38
|
-
if (callee.type === 'Identifier' && callee.name === 'setTimeout') hasSetTimeout = true;
|
|
39
|
-
// addEventListener('scroll', ...) 检测
|
|
40
|
-
if (callee.type === 'MemberExpression' && callee.property?.name === 'addEventListener') {
|
|
41
|
-
const arg = node.arguments[0];
|
|
42
|
-
if (arg?.value === 'scroll') hasScrollListener = true;
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
'Program:exit'() {
|
|
46
|
-
if (hasToast && hasSetTimeout) {
|
|
47
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'toast' });
|
|
48
|
-
}
|
|
49
|
-
if (hasSheet) {
|
|
50
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'sheet' });
|
|
51
|
-
}
|
|
52
|
-
if (hasList && hasScrollListener) {
|
|
53
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'list' });
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
},
|
|
58
|
-
};
|
|
1
|
+
// L2-6 af-mobile/prefer-component(warn)
|
|
2
|
+
// 检测:(a) .toast class + setTimeout / (b) .sheet class vs <af-action-sheet> / (c) .list class + scroll 监听
|
|
3
|
+
import { extractAllClassLists } from '../utils/helpers.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: { description: '建议使用 L3 组件替代手动实现' },
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: {
|
|
11
|
+
toast: "Manual .toast + setTimeout detected, prefer <af-toast> for singleton queue / auto-dismiss / aria-live",
|
|
12
|
+
sheet: "Manual .sheet class detected, prefer <af-action-sheet> for touch-friendly bottom sheet / gesture dismiss",
|
|
13
|
+
list: "Manual .list + scroll listener detected, prefer <af-list> for virtual scroll / pull refresh / load more",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
const filename = context.filename || context.getFilename();
|
|
18
|
+
// 组件源码自身实现不适用此规则(避免 af-list.js 报"prefer <af-list>"的自指误报)
|
|
19
|
+
if (/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
|
|
20
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
21
|
+
let hasToast = false, hasSheet = false, hasList = false;
|
|
22
|
+
let hasSetTimeout = false, hasScrollListener = false;
|
|
23
|
+
|
|
24
|
+
function scan(str) {
|
|
25
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
26
|
+
if (classes.includes('toast')) hasToast = true;
|
|
27
|
+
if (classes.includes('sheet')) hasSheet = true;
|
|
28
|
+
if (classes.includes('list')) hasList = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
Literal(node) { if (typeof node.value === 'string') scan(node.value); },
|
|
34
|
+
TemplateElement(node) { if (node.value?.raw) scan(node.value.raw); },
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
// setTimeout 检测
|
|
37
|
+
const callee = node.callee;
|
|
38
|
+
if (callee.type === 'Identifier' && callee.name === 'setTimeout') hasSetTimeout = true;
|
|
39
|
+
// addEventListener('scroll', ...) 检测
|
|
40
|
+
if (callee.type === 'MemberExpression' && callee.property?.name === 'addEventListener') {
|
|
41
|
+
const arg = node.arguments[0];
|
|
42
|
+
if (arg?.value === 'scroll') hasScrollListener = true;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
'Program:exit'() {
|
|
46
|
+
if (hasToast && hasSetTimeout) {
|
|
47
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'toast' });
|
|
48
|
+
}
|
|
49
|
+
if (hasSheet) {
|
|
50
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'sheet' });
|
|
51
|
+
}
|
|
52
|
+
if (hasList && hasScrollListener) {
|
|
53
|
+
context.report({ loc: { line: 1, column: 0 }, messageId: 'list' });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
package/rules/token-whitelist.js
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
// L2-1 af-mobile/token-whitelist(error)
|
|
2
|
-
// 检测:class="" 中的 class / 自定义元素 tagName → 与 whitelist + extraClass/extraComponents 对比
|
|
3
|
-
// 模板插值 class(btn-${type})无法静态解析 → 单独报 interpolatedClass(伪报为"不在白名单"会误导修法)
|
|
4
|
-
import { ALL_CLASSES, ALL_COMPONENTS, extractAllClassLists, extractCustomElements, suggestNearest } from '../utils/helpers.js';
|
|
5
|
-
|
|
6
|
-
export default {
|
|
7
|
-
meta: {
|
|
8
|
-
type: 'problem',
|
|
9
|
-
docs: { description: 'class 名和自定义元素必须在白名单内' },
|
|
10
|
-
schema: [{
|
|
11
|
-
type: 'object',
|
|
12
|
-
properties: {
|
|
13
|
-
extraClass: { type: 'array', items: { type: 'string' } },
|
|
14
|
-
extraComponents: { type: 'array', items: { type: 'string' } },
|
|
15
|
-
},
|
|
16
|
-
additionalProperties: false,
|
|
17
|
-
}],
|
|
18
|
-
messages: {
|
|
19
|
-
unknownClass: "Class '{{name}}' not in whitelist.{{suggest}} Use recipe/atomic, or paste into eslint.config.js: 'af-mobile/token-whitelist': ['error', { extraClass: ['{{name}}'] }]",
|
|
20
|
-
unknownComponent: "Component '{{name}}' not in whitelist.{{suggest}} Paste into eslint.config.js: 'af-mobile/token-whitelist': ['error', { extraComponents: ['{{name}}'] }]",
|
|
21
|
-
interpolatedClass: "Class '{{name}}' contains template interpolation and cannot be checked statically. Use static whitelist classes plus data-* attributes, or a ternary switching between whitelisted classes",
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
create(context) {
|
|
25
|
-
const options = context.options[0] || {};
|
|
26
|
-
const allowClass = new Set([...ALL_CLASSES, ...(options.extraClass || [])]);
|
|
27
|
-
const allowComp = new Set([...ALL_COMPONENTS, ...(options.extraComponents || [])]);
|
|
28
|
-
const extraClasses = [...allowClass];
|
|
29
|
-
const extraComps = [...allowComp];
|
|
30
|
-
|
|
31
|
-
function checkString(str, node) {
|
|
32
|
-
// class 检查
|
|
33
|
-
for (const { classes } of extractAllClassLists(str)) {
|
|
34
|
-
for (const cls of classes) {
|
|
35
|
-
if (cls.includes('${')) {
|
|
36
|
-
context.report({ node, messageId: 'interpolatedClass', data: { name: cls } });
|
|
37
|
-
} else if (!allowClass.has(cls)) {
|
|
38
|
-
context.report({ node, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
// 自定义元素检查
|
|
43
|
-
for (const tag of extractCustomElements(str)) {
|
|
44
|
-
if (!allowComp.has(tag)) {
|
|
45
|
-
context.report({ node, messageId: 'unknownComponent', data: { name: tag, suggest: suggestNearest(tag, extraComps) } });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
52
|
-
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
53
|
-
// 检测 classList.add/remove/toggle 的字符串参数(绕过 class="..." 字面量检测)
|
|
54
|
-
CallExpression(node) {
|
|
55
|
-
const callee = node.callee;
|
|
56
|
-
if (callee?.type !== 'MemberExpression') return;
|
|
57
|
-
if (callee.object?.type !== 'MemberExpression') return;
|
|
58
|
-
if (callee.object.property?.name !== 'classList') return;
|
|
59
|
-
const method = callee.property?.name;
|
|
60
|
-
if (!['add', 'remove', 'toggle'].includes(method)) return;
|
|
61
|
-
for (const arg of node.arguments) {
|
|
62
|
-
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
63
|
-
for (const cls of arg.value.split(/\s+/).filter(Boolean)) {
|
|
64
|
-
if (!allowClass.has(cls)) {
|
|
65
|
-
context.report({ node: arg, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (arg.type === 'ArrayExpression') {
|
|
70
|
-
for (const el of arg.elements) {
|
|
71
|
-
if (el?.type === 'Literal' && typeof el.value === 'string') {
|
|
72
|
-
for (const cls of el.value.split(/\s+/).filter(Boolean)) {
|
|
73
|
-
if (!allowClass.has(cls)) {
|
|
74
|
-
context.report({ node: el, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
},
|
|
84
|
-
};
|
|
1
|
+
// L2-1 af-mobile/token-whitelist(error)
|
|
2
|
+
// 检测:class="" 中的 class / 自定义元素 tagName → 与 whitelist + extraClass/extraComponents 对比
|
|
3
|
+
// 模板插值 class(btn-${type})无法静态解析 → 单独报 interpolatedClass(伪报为"不在白名单"会误导修法)
|
|
4
|
+
import { ALL_CLASSES, ALL_COMPONENTS, extractAllClassLists, extractCustomElements, suggestNearest } from '../utils/helpers.js';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: { description: 'class 名和自定义元素必须在白名单内' },
|
|
10
|
+
schema: [{
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
extraClass: { type: 'array', items: { type: 'string' } },
|
|
14
|
+
extraComponents: { type: 'array', items: { type: 'string' } },
|
|
15
|
+
},
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
}],
|
|
18
|
+
messages: {
|
|
19
|
+
unknownClass: "Class '{{name}}' not in whitelist.{{suggest}} Use recipe/atomic, or paste into eslint.config.js: 'af-mobile/token-whitelist': ['error', { extraClass: ['{{name}}'] }]",
|
|
20
|
+
unknownComponent: "Component '{{name}}' not in whitelist.{{suggest}} Paste into eslint.config.js: 'af-mobile/token-whitelist': ['error', { extraComponents: ['{{name}}'] }]",
|
|
21
|
+
interpolatedClass: "Class '{{name}}' contains template interpolation and cannot be checked statically. Use static whitelist classes plus data-* attributes, or a ternary switching between whitelisted classes",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
const options = context.options[0] || {};
|
|
26
|
+
const allowClass = new Set([...ALL_CLASSES, ...(options.extraClass || [])]);
|
|
27
|
+
const allowComp = new Set([...ALL_COMPONENTS, ...(options.extraComponents || [])]);
|
|
28
|
+
const extraClasses = [...allowClass];
|
|
29
|
+
const extraComps = [...allowComp];
|
|
30
|
+
|
|
31
|
+
function checkString(str, node) {
|
|
32
|
+
// class 检查
|
|
33
|
+
for (const { classes } of extractAllClassLists(str)) {
|
|
34
|
+
for (const cls of classes) {
|
|
35
|
+
if (cls.includes('${')) {
|
|
36
|
+
context.report({ node, messageId: 'interpolatedClass', data: { name: cls } });
|
|
37
|
+
} else if (!allowClass.has(cls)) {
|
|
38
|
+
context.report({ node, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 自定义元素检查
|
|
43
|
+
for (const tag of extractCustomElements(str)) {
|
|
44
|
+
if (!allowComp.has(tag)) {
|
|
45
|
+
context.report({ node, messageId: 'unknownComponent', data: { name: tag, suggest: suggestNearest(tag, extraComps) } });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
Literal(node) { if (typeof node.value === 'string') checkString(node.value, node); },
|
|
52
|
+
TemplateElement(node) { if (node.value?.raw) checkString(node.value.raw, node); },
|
|
53
|
+
// 检测 classList.add/remove/toggle 的字符串参数(绕过 class="..." 字面量检测)
|
|
54
|
+
CallExpression(node) {
|
|
55
|
+
const callee = node.callee;
|
|
56
|
+
if (callee?.type !== 'MemberExpression') return;
|
|
57
|
+
if (callee.object?.type !== 'MemberExpression') return;
|
|
58
|
+
if (callee.object.property?.name !== 'classList') return;
|
|
59
|
+
const method = callee.property?.name;
|
|
60
|
+
if (!['add', 'remove', 'toggle'].includes(method)) return;
|
|
61
|
+
for (const arg of node.arguments) {
|
|
62
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
63
|
+
for (const cls of arg.value.split(/\s+/).filter(Boolean)) {
|
|
64
|
+
if (!allowClass.has(cls)) {
|
|
65
|
+
context.report({ node: arg, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (arg.type === 'ArrayExpression') {
|
|
70
|
+
for (const el of arg.elements) {
|
|
71
|
+
if (el?.type === 'Literal' && typeof el.value === 'string') {
|
|
72
|
+
for (const cls of el.value.split(/\s+/).filter(Boolean)) {
|
|
73
|
+
if (!allowClass.has(cls)) {
|
|
74
|
+
context.report({ node: el, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|