@af-mobile/eslint-plugin 2.0.2 → 2.1.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  // af-mobile UI —— eslint-plugin-af-mobile 入口(ESLint 9 flat config 兼容)
2
- // 20 条规则:L1(2) + L2(7) + L3(6) + L3.5(5)
2
+ // 24 条规则:L1(2) + L2(8) + L3(6) + L3.5(5) + k(3)
3
3
  // (definePage 消费端 7 条规则已随 definePage 全局单例移除)
4
4
  import noTokenModification from './rules/no-token-modification.js';
5
5
  import noInlineStyle from './rules/no-inline-style.js';
@@ -10,6 +10,7 @@ import noArbitraryValue from './rules/no-arbitrary-value.js';
10
10
  import noTailwindSyntax from './rules/no-tailwind-syntax.js';
11
11
  import preferComponent from './rules/prefer-component.js';
12
12
  import atomicDuplicate from './rules/atomic-duplicate.js';
13
+ import noEmojiIcon from './rules/no-emoji-icon.js';
13
14
  import noRegisterAll from './rules/no-register-all.js';
14
15
  import wcLightNoStyle from './rules/wc-light-no-style.js';
15
16
  import wcShadowUseToken from './rules/wc-shadow-use-token.js';
@@ -21,15 +22,18 @@ import wcBlockNoInternalRef from './rules/wc-block-no-internal-ref.js';
21
22
  import wcBlockPropsCount from './rules/wc-block-props-count.js';
22
23
  import wcBlockStates from './rules/wc-block-states.js';
23
24
  import wcBlockVariantEnum from './rules/wc-block-variant-enum.js';
25
+ import kNoBareAnd from './rules/k-no-bare-and.js';
26
+ import kNoObjectInterpolation from './rules/k-no-object-interpolation.js';
27
+ import kForRequireKey from './rules/k-for-require-key.js';
24
28
 
25
29
  const plugin = {
26
- meta: { name: 'eslint-plugin-af-mobile', version: '2.0.1' },
30
+ meta: { name: 'eslint-plugin-af-mobile', version: '2.1.0' },
27
31
  rules: {
28
32
  // L1(2 条,全部 error;tokens-css-locked 由 CODEOWNERS + 分支保护,非 ESLint 规则)
29
33
  'no-token-modification': noTokenModification,
30
34
  'no-inline-style': noInlineStyle,
31
35
  // L1-3 tokens-css-locked 是 CODEOWNERS + 分支保护,非 ESLint 规则
32
- // L2(7 条:4 error + 3 warn)
36
+ // L2(8 条:4 error + 4 warn)
33
37
  'token-whitelist': tokenWhitelist,
34
38
  'no-recipe-break': noRecipeBreak,
35
39
  'no-variant-conflict': noVariantConflict,
@@ -37,6 +41,7 @@ const plugin = {
37
41
  'no-tailwind-syntax': noTailwindSyntax,
38
42
  'prefer-component': preferComponent,
39
43
  'atomic-duplicate': atomicDuplicate,
44
+ 'no-emoji-icon': noEmojiIcon,
40
45
  // L3(6 条:4 error + 2 warn)
41
46
  'wc-light-no-style': wcLightNoStyle,
42
47
  'wc-shadow-use-token': wcShadowUseToken,
@@ -50,6 +55,11 @@ const plugin = {
50
55
  'wc-block-props-count': wcBlockPropsCount,
51
56
  'wc-block-states': wcBlockStates,
52
57
  'wc-block-variant-enum': wcBlockVariantEnum,
58
+ // k 层(3 条:D-001=B 应用层推广的配套约束;仅对从 @af-mobile/ui/k 导入的 html/For 生效,
59
+ // 主包同名 API 语义不同不受约束,规则内经 collectKImports 判定)
60
+ 'k-no-bare-and': kNoBareAnd,
61
+ 'k-no-object-interpolation': kNoObjectInterpolation,
62
+ 'k-for-require-key': kForRequireKey,
53
63
  },
54
64
  configs: {},
55
65
  };
@@ -73,6 +83,7 @@ Object.assign(plugin.configs, {
73
83
  'af-mobile/no-variant-conflict': 'warn',
74
84
  'af-mobile/prefer-component': 'warn',
75
85
  'af-mobile/atomic-duplicate': 'warn',
86
+ 'af-mobile/no-emoji-icon': 'warn',
76
87
  // L3 error
77
88
  'af-mobile/wc-light-no-style': 'error',
78
89
  'af-mobile/wc-shadow-use-token': 'error',
@@ -88,6 +99,11 @@ Object.assign(plugin.configs, {
88
99
  // L3.5 error(按需引入铁律:registerAll 全量注册 = 全局引入,一律禁止)
89
100
  'af-mobile/no-register-all': 'error',
90
101
  'af-mobile/wc-block-variant-enum': 'warn',
102
+ // k 层(k 渲染/应用层:裸 && 渲染 "false"、对象插值 "[object Object]" 是静默 bug → error;
103
+ // For 缺 key 对对象列表破坏 keyed 复用但原始值列表合法 → warn)
104
+ 'af-mobile/k-no-bare-and': 'error',
105
+ 'af-mobile/k-no-object-interpolation': 'error',
106
+ 'af-mobile/k-for-require-key': 'warn',
91
107
  },
92
108
  },
93
109
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@af-mobile/eslint-plugin",
3
- "version": "2.0.2",
4
- "description": "af-mobile UI ESLint plugin — 27 rules (L1/L2/L3/L3.5) enforcing design-system + Web Component constraints",
3
+ "version": "2.1.0",
4
+ "description": "af-mobile UI ESLint plugin — 24 rules (L1/L2/L3/L3.5/k) enforcing design-system + Web Component + k-layer constraints",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "module": "./index.js",
@@ -2,13 +2,21 @@
2
2
  // 检测:同一 class 属性内出现两个同属性原子(如 p-4 p-2),保留最后一个
3
3
  import { extractAllClassLists } from '../utils/helpers.js';
4
4
 
5
- // 属性前缀映射:p-* → padding, m-* → margin, t-* font-size, r-* → border-radius
5
+ // 字重类与字号类共享 t- 前缀但作用于不同属性(font-weight vs font-size),必须分桶判定:
6
+ // t-xl t-b 是"大字号+粗体"的合法排版组合,不是字号重复
7
+ // (v1.6.1 修复:旧前缀正则曾把两者混为 font-size,autofix 直接删掉字号类,破坏视觉强调)
8
+ const FONT_SIZE = new Set(['t-display', 't-xs', 't-sm', 't-md', 't-lg', 't-xl']);
9
+ const FONT_WEIGHT = new Set(['t-b', 't-m', 't-semibold']);
10
+
11
+ // 属性桶:p-* → padding, m-* → margin, t-* → font-size, r-* → border-radius, 字重类 → font-weight
6
12
  function getPropPrefix(cls) {
13
+ if (FONT_WEIGHT.has(cls)) return 'fw';
14
+ if (FONT_SIZE.has(cls)) return 't';
7
15
  const m = cls.match(/^([pmtr])-(.+)$/);
8
16
  return m ? m[1] : null;
9
17
  }
10
18
 
11
- const PROP_LABELS = { p: 'padding', m: 'margin', t: 'font-size', r: 'border-radius' };
19
+ const PROP_LABELS = { p: 'padding', m: 'margin', t: 'font-size', r: 'border-radius', fw: 'font-weight' };
12
20
 
13
21
  export default {
14
22
  meta: {
@@ -0,0 +1,33 @@
1
+ // k-3 af-mobile/k-for-require-key(warn)
2
+ // For 省略 key 时以项本身为键:原始值列表语义正确;对象列表以引用为键——
3
+ // 数据源每次 set 新数组即整行重建,keyed 复用失效(src/k/README.md 词表卡 key 边界)
4
+ // 修正:显式传 key: 'id'(稳定字段名);纯原始值列表可显式关闭本规则
5
+ // 仅对从 k 入口(@af-mobile/ui/k)导入的 For 生效
6
+ import { collectKImports } from '../utils/helpers.js';
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'suggestion',
11
+ docs: { description: 'k For 建议显式传 key(对象项省略 key 以引用为键,引用变则整行重建)' },
12
+ schema: [],
13
+ messages: {
14
+ forKeyMissing: 'For is missing "key" — object items are keyed by reference when omitted, so every source change rebuilds all rows; pass key: "id" (primitive lists may disable this rule)',
15
+ },
16
+ },
17
+ create(context) {
18
+ const kNames = collectKImports(context.sourceCode.ast.body);
19
+ if (kNames.For.size === 0) return {};
20
+ return {
21
+ CallExpression(node) {
22
+ if (node.callee.type !== 'Identifier' || !kNames.For.has(node.callee.name)) return;
23
+ const arg = node.arguments[0];
24
+ if (!arg || arg.type !== 'ObjectExpression') return;
25
+ const hasKey = arg.properties.some((p) => p.type === 'Property' && (
26
+ (p.key.type === 'Identifier' && p.key.name === 'key')
27
+ || (p.key.type === 'Literal' && p.key.value === 'key')
28
+ ));
29
+ if (!hasKey) context.report({ node: arg, messageId: 'forKeyMissing' });
30
+ },
31
+ };
32
+ },
33
+ };
@@ -0,0 +1,31 @@
1
+ // k-1 af-mobile/k-no-bare-and(error)
2
+ // k 的 html`` 子位渲染 String(val):裸 && 在假值时把字面量 "false" 渲染成文本节点
3
+ // (JSX `cond && <X/>` 的幻觉写法;k 无"假值不渲染"语义)
4
+ // 修正:三元 `${cond ? x : null}`(null → 空串)或 Show({ when, kids })
5
+ // 仅对从 k 入口(@af-mobile/ui/k)导入的 html 生效;主包 html``(字符串拼接)不受约束
6
+ import { collectKImports } from '../utils/helpers.js';
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: { description: 'k html`` 子位禁止裸 && 表达式(假值渲染 "false" 文本)' },
12
+ schema: [],
13
+ messages: {
14
+ bareAnd: 'Bare && in k html`` renders literal text "false" when falsy — use a ternary `${cond ? x : null}` or Show({ when, kids })',
15
+ },
16
+ },
17
+ create(context) {
18
+ const kNames = collectKImports(context.sourceCode.ast.body);
19
+ if (kNames.html.size === 0) return {};
20
+ return {
21
+ TaggedTemplateExpression(node) {
22
+ if (node.tag.type !== 'Identifier' || !kNames.html.has(node.tag.name)) return;
23
+ for (const expr of node.quasi.expressions) {
24
+ if (expr.type === 'LogicalExpression' && expr.operator === '&&') {
25
+ context.report({ node: expr, messageId: 'bareAnd' });
26
+ }
27
+ }
28
+ },
29
+ };
30
+ },
31
+ };
@@ -0,0 +1,31 @@
1
+ // k-2 af-mobile/k-no-object-interpolation(error)
2
+ // k 的 html`` 插值经 String(val):对象字面量渲染成 "[object Object]" 文本
3
+ // 典型来源:主包 html`` 的可信 HTML 语法 `${{ raw: '<b>x</b>' }}` 在 k 不存在(JSX/主包双重幻觉)
4
+ // 修正:插 signal/getter、字符串或 DOM 节点;数组合法(bindKids 展开为多节点)不报
5
+ // 仅对从 k 入口(@af-mobile/ui/k)导入的 html 生效;主包 html`` 的 { raw } 语法不受约束
6
+ import { collectKImports } from '../utils/helpers.js';
7
+
8
+ export default {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: { description: 'k html`` 插值禁止对象字面量(渲染 "[object Object]";k 无 { raw } 语法)' },
12
+ schema: [],
13
+ messages: {
14
+ objectInterpolation: 'Object literal in k html`` renders "[object Object]" — k has no { raw } syntax; interpolate a signal/getter, string, or DOM node instead (arrays are legal, they expand to multiple nodes)',
15
+ },
16
+ },
17
+ create(context) {
18
+ const kNames = collectKImports(context.sourceCode.ast.body);
19
+ if (kNames.html.size === 0) return {};
20
+ return {
21
+ TaggedTemplateExpression(node) {
22
+ if (node.tag.type !== 'Identifier' || !kNames.html.has(node.tag.name)) return;
23
+ for (const expr of node.quasi.expressions) {
24
+ if (expr.type === 'ObjectExpression') {
25
+ context.report({ node: expr, messageId: 'objectInterpolation' });
26
+ }
27
+ }
28
+ },
29
+ };
30
+ },
31
+ };
@@ -0,0 +1,69 @@
1
+ // L2-8 af-mobile/no-emoji-icon(warn)
2
+ // 检测 emoji 当图标——"廉价感"第一视觉来源(v1.6.1 规则化,配套 prompt 图标规范)
3
+ // 检测点(低误报,只查图标语境):
4
+ // 1. icon 属性值:tabs/tabbar 配置对象 { label: 'x', icon: '📋' }(旧 few-shot 示例的反模式)
5
+ // 2. HTML 字符串中 tab-item 元素 / data-role="icon" 元素的文本内容含 emoji
6
+ // 修正:用 24px stroke SVG(prompt 图标 path 库);af-tabbar 的 icon 仅支持文本字符,需要图标时省略 icon 字段
7
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
8
+
9
+ function firstEmoji(str) {
10
+ const m = str.match(EMOJI_RE);
11
+ return m ? m[0] : null;
12
+ }
13
+
14
+ // HTML 字符串中的图标语境:tab-item 元素 / data-role="icon" 元素(取开闭标签之间的纯文本段)
15
+ const ICON_CONTEXT_RES = [
16
+ /<[\w-]+[^>]*\bclass="[^"]*\btab-item\b[^"]*"[^>]*>([^<]*)</g,
17
+ /<[\w-]+[^>]*\bdata-role="icon"[^>]*>([^<]*)</g,
18
+ ];
19
+
20
+ function checkHtmlString(str, node, report) {
21
+ for (const re of ICON_CONTEXT_RES) {
22
+ re.lastIndex = 0;
23
+ let m;
24
+ while ((m = re.exec(str))) {
25
+ const emoji = firstEmoji(m[1]);
26
+ if (emoji) report(node, emoji);
27
+ }
28
+ }
29
+ }
30
+
31
+ export default {
32
+ meta: {
33
+ type: 'suggestion',
34
+ docs: { description: '禁止 emoji 当图标(用 24px stroke SVG)' },
35
+ schema: [],
36
+ messages: {
37
+ emojiIcon: "Emoji {{emoji}} used as icon — use a 24px stroke SVG instead; af-tabbar's icon prop only supports text characters",
38
+ },
39
+ },
40
+ create(context) {
41
+ const report = (node, emoji) => context.report({ node, messageId: 'emojiIcon', data: { emoji } });
42
+ return {
43
+ // { icon: '📋' } 配置对象(af-tabbar / af-tabs 的 tabs 数组)
44
+ Property(node) {
45
+ const keyName = node.key.type === 'Identifier'
46
+ ? node.key.name
47
+ : (node.key.type === 'Literal' ? String(node.key.value) : null);
48
+ if (keyName !== 'icon') return;
49
+ const v = node.value;
50
+ if (v.type === 'Literal' && typeof v.value === 'string') {
51
+ const emoji = firstEmoji(v.value);
52
+ if (emoji) report(v, emoji);
53
+ } else if (v.type === 'TemplateLiteral' && v.expressions.length === 0) {
54
+ const emoji = firstEmoji(v.quasis[0].value.raw);
55
+ if (emoji) report(v, emoji);
56
+ }
57
+ },
58
+ // HTML 字符串(含 template literal)中的图标语境元素
59
+ Literal(node) {
60
+ if (typeof node.value !== 'string') return;
61
+ checkHtmlString(node.value, node, report);
62
+ },
63
+ TemplateElement(node) {
64
+ if (!node.value || !node.value.raw) return;
65
+ checkHtmlString(node.value.raw, node, report);
66
+ },
67
+ };
68
+ },
69
+ };
@@ -66,13 +66,15 @@ export default {
66
66
  additionalProperties: false,
67
67
  }],
68
68
  messages: {
69
- forbidden: "Inline style '{{value}}' is forbidden. Use class instead",
69
+ // v1.6.1:报错带具体违规属性名(旧报错只给整段 style,AI 修正时只能整段删掉重猜,加剧样式削平)
70
+ forbidden: "Inline style property '{{prop}}' is forbidden in '{{value}}'. Use class instead",
70
71
  },
71
72
  },
72
73
 
73
74
  create(context) {
74
75
  const options = context.options[0] || {};
75
76
  const extraAllow = new Set(options.allowProperties || []);
77
+ const nodeText = (node) => (context.sourceCode || context.getSourceCode()).getText(node);
76
78
 
77
79
  return {
78
80
  // 检测字符串字面量(含 template literal)
@@ -81,7 +83,7 @@ export default {
81
83
  const violations = checkStyleString(node.value);
82
84
  for (const v of violations) {
83
85
  if (extraAllow.has(v.prop)) continue;
84
- context.report({ node, messageId: 'forbidden', data: { value: v.value } });
86
+ context.report({ node, messageId: 'forbidden', data: { prop: v.prop, value: v.value } });
85
87
  }
86
88
  },
87
89
  TemplateElement(node) {
@@ -89,7 +91,7 @@ export default {
89
91
  const violations = checkStyleString(node.value.raw);
90
92
  for (const v of violations) {
91
93
  if (extraAllow.has(v.prop)) continue;
92
- context.report({ node, messageId: 'forbidden', data: { value: v.value } });
94
+ context.report({ node, messageId: 'forbidden', data: { prop: v.prop, value: v.value } });
93
95
  }
94
96
  },
95
97
  // 检测 el.style.setProperty('color', ...) / el.style.xxx = '...' 绕过
@@ -106,7 +108,7 @@ export default {
106
108
  if (propArg.value.startsWith('--')) return;
107
109
  const prop = propArg.value.toLowerCase();
108
110
  if (FORBIDDEN.has(prop) && !ALLOWED_LAYOUT.has(prop) && !extraAllow.has(prop)) {
109
- context.report({ node, messageId: 'forbidden', data: { value: `${prop}` } });
111
+ context.report({ node, messageId: 'forbidden', data: { prop, value: nodeText(node) } });
110
112
  }
111
113
  },
112
114
  AssignmentExpression(node) {
@@ -119,7 +121,7 @@ export default {
119
121
  // kebab-case 转换:backgroundColor → background-color
120
122
  const kebab = propName.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
121
123
  if (FORBIDDEN.has(kebab) && !ALLOWED_LAYOUT.has(kebab) && !extraAllow.has(kebab)) {
122
- context.report({ node, messageId: 'forbidden', data: { value: kebab } });
124
+ context.report({ node, messageId: 'forbidden', data: { prop: kebab, value: nodeText(node) } });
123
125
  }
124
126
  },
125
127
  };
@@ -18,7 +18,7 @@ export default {
18
18
  messages: {
19
19
  cellFlex: ".cell has built-in display:flex, adding 'f'/'fc' may break layout",
20
20
  btnColor: ".btn background uses --c-brand, text color must keep --c-onbrand contrast. Use .btn-ghost instead for colored text",
21
- inputFont: ".input font-size must be 16px (--t-md) to prevent iOS focus zoom. Put help text in <label> or .form-err instead",
21
+ inputFont: "form control (.input/.textarea/.search-input) font-size must be 16px (--t-input) to prevent iOS focus zoom. Put help text in <label> or .form-err instead",
22
22
  btnBg: ".btn background is --c-brand with white text; overriding background with 'bg-card'/'bg-muted' breaks contrast. Use .btn-ghost instead",
23
23
  },
24
24
  },
@@ -41,8 +41,8 @@ export default {
41
41
  if (set.has(a)) { context.report({ node, messageId: 'btnBg' }); break; }
42
42
  }
43
43
  }
44
- // c: .input + t-sm/t-xs
45
- if (set.has('input')) {
44
+ // c: 表单控件 + t-sm/t-xs(.input/.textarea/.search-input 均为 --t-input 16px 基准)
45
+ if (set.has('input') || set.has('textarea') || set.has('search-input')) {
46
46
  for (const a of SMALL_FONT) {
47
47
  if (set.has(a)) { context.report({ node, messageId: 'inputFont' }); break; }
48
48
  }
@@ -6,16 +6,27 @@ import { extractAllClassLists } from '../utils/helpers.js';
6
6
  const CONFLICT_GROUPS = [
7
7
  ['btn-sm', 'btn-lg'],
8
8
  ['tag-ok', 'tag-warn', 'tag-danger'],
9
+ // 描边式语义色标签(v1.6.1 新增,同 tag 语义色互斥规则)
10
+ ['tag-plain-ok', 'tag-plain-warn', 'tag-plain-danger'],
9
11
  // 圆角类
10
12
  ['r-s', 'r-m', 'r-l', 'r-f'],
11
13
  // padding 类
12
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'],
13
18
  // display 类(同为 display 属性)
14
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'],
15
24
  // 行高类
16
25
  ['lh-tight', 'lh-normal'],
17
- // 背景类
18
- ['bg-brand', 'bg-card', 'bg-muted'],
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'],
19
30
  ];
20
31
 
21
32
  export default {
@@ -1,6 +1,7 @@
1
1
  // L2-1 af-mobile/token-whitelist(error)
2
2
  // 检测:class="" 中的 class / 自定义元素 tagName → 与 whitelist + extraClass/extraComponents 对比
3
- import { ALL_CLASSES, ALL_COMPONENTS, extractAllClassLists, extractCustomElements } from '../utils/helpers.js';
3
+ // 模板插值 class(btn-${type})无法静态解析 单独报 interpolatedClass(伪报为"不在白名单"会误导修法)
4
+ import { ALL_CLASSES, ALL_COMPONENTS, extractAllClassLists, extractCustomElements, suggestNearest } from '../utils/helpers.js';
4
5
 
5
6
  export default {
6
7
  meta: {
@@ -15,28 +16,33 @@ export default {
15
16
  additionalProperties: false,
16
17
  }],
17
18
  messages: {
18
- unknownClass: "Class '{{name}}' not in whitelist. Use recipe/atomic or register in 'af-mobile/token-whitelist' rule's extraClass",
19
- unknownComponent: "Component '{{name}}' not in whitelist. Register in 'af-mobile/token-whitelist' rule's extraComponents",
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",
20
22
  },
21
23
  },
22
24
  create(context) {
23
25
  const options = context.options[0] || {};
24
26
  const allowClass = new Set([...ALL_CLASSES, ...(options.extraClass || [])]);
25
27
  const allowComp = new Set([...ALL_COMPONENTS, ...(options.extraComponents || [])]);
28
+ const extraClasses = [...allowClass];
29
+ const extraComps = [...allowComp];
26
30
 
27
31
  function checkString(str, node) {
28
32
  // class 检查
29
33
  for (const { classes } of extractAllClassLists(str)) {
30
34
  for (const cls of classes) {
31
- if (!allowClass.has(cls)) {
32
- context.report({ node, messageId: 'unknownClass', data: { name: cls } });
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) } });
33
39
  }
34
40
  }
35
41
  }
36
42
  // 自定义元素检查
37
43
  for (const tag of extractCustomElements(str)) {
38
44
  if (!allowComp.has(tag)) {
39
- context.report({ node, messageId: 'unknownComponent', data: { name: tag } });
45
+ context.report({ node, messageId: 'unknownComponent', data: { name: tag, suggest: suggestNearest(tag, extraComps) } });
40
46
  }
41
47
  }
42
48
  }
@@ -56,7 +62,7 @@ export default {
56
62
  if (arg.type === 'Literal' && typeof arg.value === 'string') {
57
63
  for (const cls of arg.value.split(/\s+/).filter(Boolean)) {
58
64
  if (!allowClass.has(cls)) {
59
- context.report({ node: arg, messageId: 'unknownClass', data: { name: cls } });
65
+ context.report({ node: arg, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
60
66
  }
61
67
  }
62
68
  }
@@ -65,7 +71,7 @@ export default {
65
71
  if (el?.type === 'Literal' && typeof el.value === 'string') {
66
72
  for (const cls of el.value.split(/\s+/).filter(Boolean)) {
67
73
  if (!allowClass.has(cls)) {
68
- context.report({ node: el, messageId: 'unknownClass', data: { name: cls } });
74
+ context.report({ node: el, messageId: 'unknownClass', data: { name: cls, suggest: suggestNearest(cls, extraClasses) } });
69
75
  }
70
76
  }
71
77
  }
@@ -22,7 +22,7 @@ export default {
22
22
  },
23
23
  create(context) {
24
24
  const filename = context.filename || context.getFilename();
25
- if (!/src[\\/](?:charts[\\/])?components[\\/].*\.js$/.test(filename)) return {};
25
+ if (!/src[\\/](?:(?:charts|chat)[\\/])?components[\\/].*\.js$/.test(filename)) return {};
26
26
 
27
27
  // 从文件名提取组件名(af-xxx.js → af-xxx)
28
28
  const m = filename.match(/(af-[a-z-]+)\.js$/);
@@ -39,6 +39,15 @@
39
39
  "role": "listbox",
40
40
  "description": "选择器列需要 role=listbox"
41
41
  },
42
+ "af-number-keyboard": {
43
+ "role": "dialog",
44
+ "ariaLabel": true,
45
+ "description": "数字键盘弹层需要 role=dialog 和 aria-label;按键为原生 button 天然支持键盘操作"
46
+ },
47
+ "af-password-input": {
48
+ "ariaLabel": true,
49
+ "description": "密码格子容器需要 aria-label(含输入进度播报)"
50
+ },
42
51
  "af-img": {
43
52
  "description": "图片组件无强制 ARIA 要求"
44
53
  },
@@ -83,5 +92,10 @@
83
92
  "role": "img",
84
93
  "ariaLabel": true,
85
94
  "description": "漏斗图 svg 需要 role=img 和 aria-label 摘要(全量数据由 sr-table 提供)"
95
+ },
96
+ "af-chat": {
97
+ "role": "log",
98
+ "ariaLive": true,
99
+ "description": "对话记录容器需要 role=log 和 aria-live=polite(流式文本 aria-hidden,完成时 sr-only 一次性播报)"
86
100
  }
87
101
  }
package/utils/helpers.js CHANGED
@@ -53,3 +53,54 @@ export function extractCustomElements(str) {
53
53
  while ((m = re.exec(str))) results.push(m[1]);
54
54
  return results;
55
55
  }
56
+
57
+ // 编辑距离(Levenshtein):最近邻建议用,候选集小(<200)无需提前剪枝
58
+ function levenshtein(a, b) {
59
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
60
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
61
+ for (let i = 1; i <= a.length; i++) {
62
+ for (let j = 1; j <= b.length; j++) {
63
+ dp[i][j] = Math.min(
64
+ dp[i - 1][j] + 1,
65
+ dp[i][j - 1] + 1,
66
+ dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
67
+ );
68
+ }
69
+ }
70
+ return dp[a.length][b.length];
71
+ }
72
+
73
+ // 最近邻建议:返回 " Did you mean 'xxx'?" 或 ''(无足够接近的候选)
74
+ // 阈值随长度放宽:≤3 字符允许 1 距离,更长允许 2
75
+ export function suggestNearest(name, candidates) {
76
+ const max = name.length <= 3 ? 1 : 2;
77
+ let best = null, bestDist = max + 1;
78
+ for (const c of candidates) {
79
+ if (Math.abs(c.length - name.length) > max) continue;
80
+ const d = levenshtein(name, c);
81
+ if (d < bestDist) { best = c; bestDist = d; }
82
+ }
83
+ return best ? ` Did you mean '${best}'?` : '';
84
+ }
85
+
86
+ // k 层导入源:发布名 @af-mobile/ui/k,或仓库内相对路径 k/index.js / k/flow.js
87
+ const K_SOURCE_RE = /^@af-mobile\/ui\/k$|(?:^|[\\/])k[\\/](?:index|flow)\.[cm]?js$/;
88
+
89
+ // 收集从 k 入口导入的绑定名(含别名):{ html: Set<localName>, For: Set<localName> }
90
+ // k 层规则(k-no-bare-and 等)只对从 k 入口导入的 html/For 生效——
91
+ // 主包 html``(返回字符串)语义不同,不受 k 规则约束
92
+ export function collectKImports(programBody) {
93
+ const names = { html: new Set(), For: new Set() };
94
+ for (const stmt of programBody) {
95
+ if (stmt.type !== 'ImportDeclaration') continue;
96
+ if (!K_SOURCE_RE.test(stmt.source.value)) continue;
97
+ for (const spec of stmt.specifiers) {
98
+ if (spec.type !== 'ImportSpecifier') continue;
99
+ const imported = spec.imported.type === 'Identifier'
100
+ ? spec.imported.name
101
+ : String(spec.imported.value);
102
+ if (names[imported]) names[imported].add(spec.local.name);
103
+ }
104
+ }
105
+ return names;
106
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "v1",
3
- "af-mobileVersion": "1.0.0",
3
+ "af-mobileVersion": "1.7.0",
4
4
  "classes": {
5
5
  "recipe": [
6
6
  "actions",
@@ -12,43 +12,58 @@
12
12
  "btn-danger",
13
13
  "btn-ghost",
14
14
  "btn-lg",
15
+ "btn-mini",
16
+ "btn-plain",
17
+ "btn-round",
15
18
  "btn-sm",
16
19
  "btn-success",
17
20
  "caption",
18
21
  "card",
22
+ "card-media",
23
+ "card-shadow",
19
24
  "cell",
20
25
  "center",
21
26
  "checkbox",
22
27
  "checkbox-sm",
23
- "checkout-bar",
24
- "collapse",
25
- "collapse-content",
26
- "collapse-summary",
28
+ "chips",
29
+ "clp",
30
+ "clp-ct",
31
+ "clp-sum",
32
+ "cob",
33
+ "cob-fx",
27
34
  "display",
28
35
  "divider",
36
+ "dot",
37
+ "dot-on",
38
+ "dots",
29
39
  "empty",
30
40
  "eyebrow",
31
41
  "form-err",
32
42
  "form-row",
33
43
  "form-row-h",
34
44
  "hero",
45
+ "hero-grad",
46
+ "icon-badge",
35
47
  "input",
36
48
  "input-bar",
49
+ "input-bar-fx",
37
50
  "input-err",
38
51
  "label",
39
52
  "list",
40
53
  "list-item",
41
- "list-item-compact",
54
+ "list-item-cp",
42
55
  "meta",
56
+ "nav-transparent",
43
57
  "navbar",
44
58
  "navbar-fixed",
45
59
  "notice",
46
- "notice-scroll",
47
- "notice-text",
60
+ "notice-scr",
61
+ "notice-tx",
48
62
  "page",
49
63
  "page-col",
50
64
  "price",
51
65
  "price-del",
66
+ "price-lg",
52
67
  "progress",
53
68
  "progress-danger",
54
69
  "progress-lg",
@@ -58,33 +73,37 @@
58
73
  "radio-sm",
59
74
  "rate",
60
75
  "rate-lg",
61
- "rate-readonly",
76
+ "rate-ro",
62
77
  "rate-sm",
63
78
  "rate-star",
79
+ "safe-bottom",
80
+ "safe-top",
81
+ "sb-clear",
82
+ "sb-icon",
83
+ "sb-wrap",
64
84
  "scroll-y",
65
- "search-bar-clear",
66
- "search-bar-icon",
67
- "search-bar-wrap",
68
85
  "search-input",
69
86
  "section",
70
- "section-title",
71
- "segmented",
72
- "segmented-block",
73
- "segmented-item",
87
+ "section-tt",
88
+ "seg",
89
+ "seg-blk",
90
+ "seg-brand",
91
+ "seg-it",
74
92
  "sheet",
75
- "skeleton",
76
- "skeleton-block",
77
- "skeleton-block-h-md",
78
- "skeleton-block-h-sm",
79
- "skeleton-circle",
80
- "skeleton-line",
81
- "skeleton-page",
82
- "skeleton-w-40",
83
- "skeleton-w-60",
84
- "skeleton-w-80",
93
+ "sk",
94
+ "sk-blk",
95
+ "sk-blk-h-md",
96
+ "sk-blk-h-sm",
97
+ "sk-cir",
98
+ "sk-ln",
99
+ "sk-pg",
100
+ "sk-w-40",
101
+ "sk-w-60",
102
+ "sk-w-80",
85
103
  "spinner",
86
104
  "spinner-lg",
87
105
  "spinner-sm",
106
+ "stat-num",
88
107
  "stats-grid",
89
108
  "step",
90
109
  "step-active",
@@ -94,16 +113,22 @@
94
113
  "steps",
95
114
  "subtitle",
96
115
  "switch",
97
- "switch-loading",
116
+ "switch-ldg",
98
117
  "switch-on",
99
118
  "switch-sm",
100
- "switch-thumb",
119
+ "switch-th",
101
120
  "tab-item",
102
121
  "tabbar",
103
122
  "tabbar-fixed",
104
123
  "tag",
105
124
  "tag-danger",
125
+ "tag-lg",
126
+ "tag-md",
106
127
  "tag-ok",
128
+ "tag-plain",
129
+ "tag-plain-danger",
130
+ "tag-plain-ok",
131
+ "tag-plain-warn",
107
132
  "tag-warn",
108
133
  "textarea",
109
134
  "thumb",
@@ -112,15 +137,26 @@
112
137
  "toast-error",
113
138
  "toast-success",
114
139
  "toast-warning",
115
- "upload-grid",
116
- "upload-trigger"
140
+ "upload-gd",
141
+ "upload-tg"
117
142
  ],
118
143
  "atomic": [
119
144
  "aic",
145
+ "aspect-1",
146
+ "aspect-16-9",
147
+ "aspect-4-3",
120
148
  "bg-brand",
149
+ "bg-brand-soft",
121
150
  "bg-card",
151
+ "bg-danger",
152
+ "bg-grad-brand",
153
+ "bg-grad-brand-soft",
122
154
  "bg-muted",
155
+ "bg-success",
156
+ "border",
157
+ "border-brand",
123
158
  "ellipsis",
159
+ "ellipsis-2",
124
160
  "f",
125
161
  "fc",
126
162
  "fi",
@@ -130,17 +166,25 @@
130
166
  "g-2",
131
167
  "g-3",
132
168
  "g-4",
169
+ "grid",
170
+ "grid-2",
171
+ "grid-3",
172
+ "hl-b",
173
+ "hl-t",
133
174
  "jcc",
134
175
  "jce",
135
176
  "jcsb",
136
177
  "lh-normal",
137
178
  "lh-tight",
179
+ "list",
138
180
  "m-0",
139
181
  "m-1",
140
182
  "m-2",
141
183
  "m-3",
142
184
  "m-4",
143
185
  "m-5",
186
+ "min-w-0",
187
+ "navbar",
144
188
  "p-0",
145
189
  "p-1",
146
190
  "p-10",
@@ -151,6 +195,14 @@
151
195
  "p-6",
152
196
  "p-7",
153
197
  "p-8",
198
+ "px-1",
199
+ "px-2",
200
+ "px-3",
201
+ "px-4",
202
+ "py-1",
203
+ "py-2",
204
+ "py-3",
205
+ "py-4",
154
206
  "r-0",
155
207
  "r-f",
156
208
  "r-l",
@@ -174,9 +226,12 @@
174
226
  "t-xs",
175
227
  "text-brand",
176
228
  "text-danger",
229
+ "text-gray-7",
177
230
  "text-muted",
231
+ "text-onbrand",
178
232
  "text-success",
179
233
  "w-full",
234
+ "wrap",
180
235
  "ws-nowrap"
181
236
  ]
182
237
  },
@@ -191,6 +246,7 @@
191
246
  "af-chart-line",
192
247
  "af-chart-pie",
193
248
  "af-chart-radar",
249
+ "af-chat",
194
250
  "af-countdown",
195
251
  "af-dialog",
196
252
  "af-dropdown",
@@ -199,6 +255,8 @@
199
255
  "af-list",
200
256
  "af-navbar",
201
257
  "af-notice-bar",
258
+ "af-number-keyboard",
259
+ "af-password-input",
202
260
  "af-picker",
203
261
  "af-progress",
204
262
  "af-pull-refresh",
@@ -223,9 +281,18 @@
223
281
  "--c-brand-strong",
224
282
  "--c-card",
225
283
  "--c-danger",
284
+ "--c-gray-1",
285
+ "--c-gray-2",
286
+ "--c-gray-3",
287
+ "--c-gray-4",
288
+ "--c-gray-5",
289
+ "--c-gray-6",
290
+ "--c-gray-7",
291
+ "--c-gray-8",
226
292
  "--c-muted",
227
293
  "--c-muted-bg",
228
294
  "--c-onbrand",
295
+ "--c-onwarn",
229
296
  "--c-success",
230
297
  "--c-text",
231
298
  "--c-warn",
@@ -251,9 +318,18 @@
251
318
  "--palette-card",
252
319
  "--palette-color-scheme",
253
320
  "--palette-danger",
321
+ "--palette-gray-1",
322
+ "--palette-gray-2",
323
+ "--palette-gray-3",
324
+ "--palette-gray-4",
325
+ "--palette-gray-5",
326
+ "--palette-gray-6",
327
+ "--palette-gray-7",
328
+ "--palette-gray-8",
254
329
  "--palette-muted",
255
330
  "--palette-muted-bg",
256
331
  "--palette-onbrand",
332
+ "--palette-onwarn",
257
333
  "--palette-shadow-lg",
258
334
  "--palette-shadow-md",
259
335
  "--palette-shadow-sm",
@@ -279,11 +355,13 @@
279
355
  "--shadow-md",
280
356
  "--shadow-sm",
281
357
  "--t-display",
358
+ "--t-input",
282
359
  "--t-lg",
283
360
  "--t-md",
284
361
  "--t-sm",
285
362
  "--t-xl",
286
363
  "--t-xs",
364
+ "--tabbar-h",
287
365
  "--z-base",
288
366
  "--z-dropdown",
289
367
  "--z-modal",