sellmate-design-system-react 9.0.0-beta.74 → 9.0.0-beta.76
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/AGENTS.md +37 -2
- package/README.md +5 -5
- package/bin/sellmate-ds.mjs +91 -59
- package/dist/components/SCircleProgress/README.md +6 -3
- package/dist/components/SCircleProgress/SCircleProgress.d.ts +11 -3
- package/dist/components/SLauncherListBox/README.md +1 -1
- package/dist/components/SLauncherListBox/launcherListBox.config.d.ts +2 -2
- package/dist/components/SLinearProgress/README.md +2 -2
- package/dist/components/SLinearProgress/SLinearProgress.d.ts +8 -2
- package/dist/components/SLogo/README.md +1 -0
- package/dist/components/SLogo/logo.config.d.ts +1 -1
- package/dist/components/SSelect/README.md +18 -2
- package/dist/components/SSelect/SSelect.d.ts +28 -5
- package/dist/components/SSelect/index.d.ts +1 -1
- package/dist/components/STable/README.md +29 -20
- package/dist/components/STable/STable.d.ts +47 -31
- package/dist/components/STable/index.d.ts +1 -1
- package/dist/index.cjs +167 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +167 -50
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +94 -31
- package/dist/llms.txt +39 -3
- package/dist/styles.css +16 -0
- package/dist/theme.css +3 -0
- package/eslint/index.mjs +42 -42
- package/eslint/lib/class-names.mjs +23 -25
- package/eslint/lib/table-column.mjs +11 -11
- package/eslint/rules/component-group-gap.mjs +32 -30
- package/eslint/rules/divider-vertical-height.mjs +22 -22
- package/eslint/rules/field-width-grade.mjs +50 -50
- package/eslint/rules/no-arbitrary-class.mjs +63 -20
- package/eslint/rules/no-off-scale-spacing.mjs +61 -41
- package/eslint/rules/no-raw-html-control.mjs +38 -36
- package/eslint/rules/prefer-typo-preset.mjs +23 -17
- package/eslint/rules/require-locale-number.mjs +14 -19
- package/eslint/rules/table-column-width.mjs +21 -17
- package/eslint/rules/table-numeric-align.mjs +14 -14
- package/eslint/scale.gen.mjs +1 -1
- package/package.json +3 -3
package/eslint/index.mjs
CHANGED
|
@@ -4,32 +4,32 @@
|
|
|
4
4
|
* AGENTS.md 의 규칙 중 정적으로 검출 가능한 것을 린트로 강제한다.
|
|
5
5
|
* 사용법은 README 의 "ESLint 설정" 절 참조.
|
|
6
6
|
*/
|
|
7
|
-
import noRawHtmlControl from
|
|
8
|
-
import noArbitraryClass from
|
|
9
|
-
import noOffScaleSpacing from
|
|
10
|
-
import preferTypoPreset from
|
|
11
|
-
import tableNumericAlign from
|
|
12
|
-
import requireLocaleNumber from
|
|
13
|
-
import componentGroupGap from
|
|
14
|
-
import fieldWidthGrade from
|
|
15
|
-
import tableColumnWidth from
|
|
16
|
-
import dividerVerticalHeight from
|
|
7
|
+
import noRawHtmlControl from './rules/no-raw-html-control.mjs';
|
|
8
|
+
import noArbitraryClass from './rules/no-arbitrary-class.mjs';
|
|
9
|
+
import noOffScaleSpacing from './rules/no-off-scale-spacing.mjs';
|
|
10
|
+
import preferTypoPreset from './rules/prefer-typo-preset.mjs';
|
|
11
|
+
import tableNumericAlign from './rules/table-numeric-align.mjs';
|
|
12
|
+
import requireLocaleNumber from './rules/require-locale-number.mjs';
|
|
13
|
+
import componentGroupGap from './rules/component-group-gap.mjs';
|
|
14
|
+
import fieldWidthGrade from './rules/field-width-grade.mjs';
|
|
15
|
+
import tableColumnWidth from './rules/table-column-width.mjs';
|
|
16
|
+
import dividerVerticalHeight from './rules/divider-vertical-height.mjs';
|
|
17
17
|
|
|
18
|
-
const PLUGIN_NAME =
|
|
18
|
+
const PLUGIN_NAME = 'sellmate';
|
|
19
19
|
|
|
20
20
|
const plugin = {
|
|
21
|
-
meta: { name:
|
|
21
|
+
meta: { name: 'eslint-plugin-sellmate' },
|
|
22
22
|
rules: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
'no-raw-html-control': noRawHtmlControl,
|
|
24
|
+
'no-arbitrary-class': noArbitraryClass,
|
|
25
|
+
'no-off-scale-spacing': noOffScaleSpacing,
|
|
26
|
+
'prefer-typo-preset': preferTypoPreset,
|
|
27
|
+
'table-numeric-align': tableNumericAlign,
|
|
28
|
+
'require-locale-number': requireLocaleNumber,
|
|
29
|
+
'component-group-gap': componentGroupGap,
|
|
30
|
+
'field-width-grade': fieldWidthGrade,
|
|
31
|
+
'table-column-width': tableColumnWidth,
|
|
32
|
+
'divider-vertical-height': dividerVerticalHeight,
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -47,28 +47,28 @@ const plugin = {
|
|
|
47
47
|
const recommended = [
|
|
48
48
|
{
|
|
49
49
|
// 컬럼 정의를 .ts 파일로 분리하는 경우가 있어 tsx 뿐 아니라 ts 도 포함한다
|
|
50
|
-
files: [
|
|
50
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
51
51
|
plugins: { [PLUGIN_NAME]: plugin },
|
|
52
52
|
rules: {
|
|
53
53
|
// 스케일 밖 `gap-sd-13` 은 아무 스타일도 만들지 않는다 — 조용히 깨진다
|
|
54
|
-
[`${PLUGIN_NAME}/no-off-scale-spacing`]:
|
|
54
|
+
[`${PLUGIN_NAME}/no-off-scale-spacing`]: 'error',
|
|
55
55
|
// 생 <button>/<table> 은 디자인 시스템을 통째로 우회하는 것 (스타일 자유와 무관)
|
|
56
|
-
[`${PLUGIN_NAME}/no-raw-html-control`]:
|
|
56
|
+
[`${PLUGIN_NAME}/no-raw-html-control`]: 'error',
|
|
57
57
|
// 세로 SDivider 에 className 으로 높이를 주면 self-stretch 가 무효가 되어 선이 위로 솟는다
|
|
58
|
-
[`${PLUGIN_NAME}/divider-vertical-height`]:
|
|
58
|
+
[`${PLUGIN_NAME}/divider-vertical-height`]: 'error',
|
|
59
59
|
|
|
60
60
|
// 아래는 디자인 컨벤션 — 권고만 한다
|
|
61
|
-
[`${PLUGIN_NAME}/prefer-typo-preset`]:
|
|
62
|
-
[`${PLUGIN_NAME}/component-group-gap`]:
|
|
63
|
-
[`${PLUGIN_NAME}/table-numeric-align`]:
|
|
64
|
-
[`${PLUGIN_NAME}/require-locale-number`]:
|
|
61
|
+
[`${PLUGIN_NAME}/prefer-typo-preset`]: 'warn',
|
|
62
|
+
[`${PLUGIN_NAME}/component-group-gap`]: 'warn',
|
|
63
|
+
[`${PLUGIN_NAME}/table-numeric-align`]: 'warn',
|
|
64
|
+
[`${PLUGIN_NAME}/require-locale-number`]: 'warn',
|
|
65
65
|
// 폭 등급 환산이 아직 잠정치라 권고에 둔다 (디자인 확정표가 오면 error 승격 검토)
|
|
66
|
-
[`${PLUGIN_NAME}/field-width-grade`]:
|
|
66
|
+
[`${PLUGIN_NAME}/field-width-grade`]: 'warn',
|
|
67
67
|
// 컬럼 폭 — 폭 미지정(기본 120px)·스페이서 오용은 권고, % · clamp() 는 조용히 깨지므로 error
|
|
68
|
-
[`${PLUGIN_NAME}/table-column-width`]:
|
|
68
|
+
[`${PLUGIN_NAME}/table-column-width`]: 'warn',
|
|
69
69
|
|
|
70
70
|
// 앱 고유 치수(w-[280px])·일회성 값에는 임의 값이 정당하다. 필요한 팀만 켠다.
|
|
71
|
-
[`${PLUGIN_NAME}/no-arbitrary-class`]:
|
|
71
|
+
[`${PLUGIN_NAME}/no-arbitrary-class`]: 'off',
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
74
|
];
|
|
@@ -84,15 +84,15 @@ const strict = [
|
|
|
84
84
|
...recommended[0],
|
|
85
85
|
rules: {
|
|
86
86
|
...recommended[0].rules,
|
|
87
|
-
[`${PLUGIN_NAME}/no-raw-html-control`]: [
|
|
88
|
-
[`${PLUGIN_NAME}/no-arbitrary-class`]:
|
|
89
|
-
[`${PLUGIN_NAME}/no-off-scale-spacing`]: [
|
|
90
|
-
[`${PLUGIN_NAME}/prefer-typo-preset`]:
|
|
91
|
-
[`${PLUGIN_NAME}/component-group-gap`]:
|
|
92
|
-
[`${PLUGIN_NAME}/table-numeric-align`]:
|
|
93
|
-
[`${PLUGIN_NAME}/require-locale-number`]:
|
|
94
|
-
[`${PLUGIN_NAME}/field-width-grade`]:
|
|
95
|
-
[`${PLUGIN_NAME}/table-column-width`]:
|
|
87
|
+
[`${PLUGIN_NAME}/no-raw-html-control`]: ['error', { strict: true }],
|
|
88
|
+
[`${PLUGIN_NAME}/no-arbitrary-class`]: 'error',
|
|
89
|
+
[`${PLUGIN_NAME}/no-off-scale-spacing`]: ['error', { requirePrefix: true }],
|
|
90
|
+
[`${PLUGIN_NAME}/prefer-typo-preset`]: 'error',
|
|
91
|
+
[`${PLUGIN_NAME}/component-group-gap`]: 'error',
|
|
92
|
+
[`${PLUGIN_NAME}/table-numeric-align`]: 'error',
|
|
93
|
+
[`${PLUGIN_NAME}/require-locale-number`]: 'error',
|
|
94
|
+
[`${PLUGIN_NAME}/field-width-grade`]: 'error',
|
|
95
|
+
[`${PLUGIN_NAME}/table-column-width`]: 'error',
|
|
96
96
|
},
|
|
97
97
|
},
|
|
98
98
|
];
|
|
@@ -10,14 +10,12 @@
|
|
|
10
10
|
* 동적으로 조립되는 부분(변수·삼항의 식별자 등)은 정적 분석 대상이 아니므로 건너뛴다.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const CLASS_ATTRS = new Set([
|
|
14
|
-
const CLASS_HELPERS = new Set([
|
|
13
|
+
const CLASS_ATTRS = new Set(['className', 'class']);
|
|
14
|
+
const CLASS_HELPERS = new Set(['cn', 'clsx', 'classNames', 'classnames', 'cva', 'twMerge']);
|
|
15
15
|
|
|
16
16
|
/** 문자열을 공백 기준 클래스 토큰으로 나눈다 (variant 접두어는 유지) */
|
|
17
17
|
export function tokenize(value) {
|
|
18
|
-
return String(value)
|
|
19
|
-
.split(/\s+/)
|
|
20
|
-
.filter(Boolean);
|
|
18
|
+
return String(value).split(/\s+/).filter(Boolean);
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/**
|
|
@@ -38,16 +36,16 @@ function indexOfTopLevelColon(s) {
|
|
|
38
36
|
let depth = 0;
|
|
39
37
|
for (let i = 0; i < s.length; i++) {
|
|
40
38
|
const c = s[i];
|
|
41
|
-
if (c ===
|
|
42
|
-
else if (c ===
|
|
43
|
-
else if (c ===
|
|
39
|
+
if (c === '[') depth++;
|
|
40
|
+
else if (c === ']') depth--;
|
|
41
|
+
else if (c === ':' && depth === 0) return i;
|
|
44
42
|
}
|
|
45
43
|
return -1;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
/** `!gap-12` / `-mt-8` 같은 접두 기호 제거 */
|
|
49
47
|
export function stripModifiers(token) {
|
|
50
|
-
return token.replace(/^!/,
|
|
48
|
+
return token.replace(/^!/, '').replace(/^-/, '');
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
/**
|
|
@@ -55,7 +53,7 @@ export function stripModifiers(token) {
|
|
|
55
53
|
* @returns {Array<{ token: string, node: import('estree').Node }>}
|
|
56
54
|
*/
|
|
57
55
|
export function collectClassTokens(attrNode) {
|
|
58
|
-
if (attrNode.type !==
|
|
56
|
+
if (attrNode.type !== 'JSXAttribute') return [];
|
|
59
57
|
const name = attrNode.name?.name;
|
|
60
58
|
if (!CLASS_ATTRS.has(name)) return [];
|
|
61
59
|
|
|
@@ -64,46 +62,46 @@ export function collectClassTokens(attrNode) {
|
|
|
64
62
|
for (const token of tokenize(raw)) out.push({ token, node });
|
|
65
63
|
};
|
|
66
64
|
|
|
67
|
-
const visit =
|
|
65
|
+
const visit = node => {
|
|
68
66
|
if (!node) return;
|
|
69
67
|
switch (node.type) {
|
|
70
|
-
case
|
|
71
|
-
if (typeof node.value ===
|
|
68
|
+
case 'Literal':
|
|
69
|
+
if (typeof node.value === 'string') push(node.value, node);
|
|
72
70
|
break;
|
|
73
|
-
case
|
|
71
|
+
case 'TemplateLiteral':
|
|
74
72
|
// 정적 부분만 본다. 보간(${}) 안쪽은 아래 표현식 방문으로 따로 처리.
|
|
75
|
-
for (const q of node.quasis) push(q.value.cooked ??
|
|
73
|
+
for (const q of node.quasis) push(q.value.cooked ?? '', node);
|
|
76
74
|
for (const e of node.expressions) visit(e);
|
|
77
75
|
break;
|
|
78
|
-
case
|
|
76
|
+
case 'JSXExpressionContainer':
|
|
79
77
|
visit(node.expression);
|
|
80
78
|
break;
|
|
81
|
-
case
|
|
79
|
+
case 'CallExpression': {
|
|
82
80
|
const callee = node.callee;
|
|
83
|
-
const fnName = callee?.type ===
|
|
81
|
+
const fnName = callee?.type === 'Identifier' ? callee.name : callee?.property?.name;
|
|
84
82
|
if (fnName && CLASS_HELPERS.has(fnName)) {
|
|
85
83
|
for (const arg of node.arguments) visit(arg);
|
|
86
84
|
}
|
|
87
85
|
break;
|
|
88
86
|
}
|
|
89
|
-
case
|
|
87
|
+
case 'ConditionalExpression':
|
|
90
88
|
visit(node.consequent);
|
|
91
89
|
visit(node.alternate);
|
|
92
90
|
break;
|
|
93
|
-
case
|
|
91
|
+
case 'LogicalExpression':
|
|
94
92
|
visit(node.left);
|
|
95
93
|
visit(node.right);
|
|
96
94
|
break;
|
|
97
|
-
case
|
|
95
|
+
case 'ArrayExpression':
|
|
98
96
|
for (const el of node.elements) visit(el);
|
|
99
97
|
break;
|
|
100
|
-
case
|
|
98
|
+
case 'ObjectExpression':
|
|
101
99
|
// clsx({ 'gap-12': cond }) 형태 — 키가 클래스다
|
|
102
100
|
for (const prop of node.properties) {
|
|
103
|
-
if (prop.type ===
|
|
104
|
-
if (prop.key.type ===
|
|
101
|
+
if (prop.type === 'Property' && prop.key) {
|
|
102
|
+
if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') {
|
|
105
103
|
push(prop.key.value, prop.key);
|
|
106
|
-
} else if (prop.key.type ===
|
|
104
|
+
} else if (prop.key.type === 'Identifier' && !prop.computed) {
|
|
107
105
|
push(prop.key.name, prop.key);
|
|
108
106
|
}
|
|
109
107
|
}
|
|
@@ -58,17 +58,17 @@ export function isNonQuantityLabel(label) {
|
|
|
58
58
|
/** 객체 리터럴에서 프로퍼티 노드를 이름으로 찾는다 */
|
|
59
59
|
export function prop(objectNode, name) {
|
|
60
60
|
return objectNode.properties.find(
|
|
61
|
-
|
|
62
|
-
p.type ===
|
|
61
|
+
p =>
|
|
62
|
+
p.type === 'Property' &&
|
|
63
63
|
!p.computed &&
|
|
64
|
-
((p.key.type ===
|
|
65
|
-
(p.key.type ===
|
|
64
|
+
((p.key.type === 'Identifier' && p.key.name === name) ||
|
|
65
|
+
(p.key.type === 'Literal' && p.key.value === name)),
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** 문자열 리터럴 값 (아니면 undefined) */
|
|
70
70
|
export function literalString(node) {
|
|
71
|
-
return node?.value?.type ===
|
|
71
|
+
return node?.value?.type === 'Literal' && typeof node.value.value === 'string'
|
|
72
72
|
? node.value.value
|
|
73
73
|
: undefined;
|
|
74
74
|
}
|
|
@@ -89,10 +89,10 @@ export function literalString(node) {
|
|
|
89
89
|
* @returns {{ nameProp, labelProp, columnName: string | undefined } | null}
|
|
90
90
|
*/
|
|
91
91
|
export function asColumnShape(node) {
|
|
92
|
-
const nameProp = prop(node,
|
|
93
|
-
const labelProp = prop(node,
|
|
92
|
+
const nameProp = prop(node, 'name');
|
|
93
|
+
const labelProp = prop(node, 'label');
|
|
94
94
|
if (!nameProp || !labelProp) return null;
|
|
95
|
-
if (!prop(node,
|
|
95
|
+
if (!prop(node, 'field') && !prop(node, 'autoWidth')) return null;
|
|
96
96
|
// name 이 문자열 리터럴이 아니면 컬럼 정의로 단정하지 않는다 (일반 객체 오탐 방지)
|
|
97
97
|
const columnName = literalString(nameProp);
|
|
98
98
|
if (columnName === undefined) return null;
|
|
@@ -104,9 +104,9 @@ export function asColumnShape(node) {
|
|
|
104
104
|
* @returns {{ nameProp, labelProp, label: string } | null}
|
|
105
105
|
*/
|
|
106
106
|
export function asColumn(node) {
|
|
107
|
-
const nameProp = prop(node,
|
|
108
|
-
const labelProp = prop(node,
|
|
109
|
-
if (!nameProp || !labelProp || !prop(node,
|
|
107
|
+
const nameProp = prop(node, 'name');
|
|
108
|
+
const labelProp = prop(node, 'label');
|
|
109
|
+
if (!nameProp || !labelProp || !prop(node, 'field')) return null;
|
|
110
110
|
|
|
111
111
|
const label = literalString(labelProp);
|
|
112
112
|
if (label === undefined) return null; // 동적 label 은 판단하지 않는다
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* AGENTS.md §2-2.
|
|
13
13
|
*/
|
|
14
|
-
import { collectClassTokens, stripVariants, stripModifiers } from
|
|
14
|
+
import { collectClassTokens, stripVariants, stripModifiers } from '../lib/class-names.mjs';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* 컴포넌트별 기대 간격 (px).
|
|
@@ -33,8 +33,8 @@ const GROUP_GAP = {
|
|
|
33
33
|
|
|
34
34
|
/** 이 컴포넌트들은 전용 그룹 컴포넌트가 따로 있다 */
|
|
35
35
|
const HAS_GROUP_COMPONENT = {
|
|
36
|
-
SRadio:
|
|
37
|
-
SRadioButton:
|
|
36
|
+
SRadio: 'SRadioGroup (direction 으로 간격을 맞춥니다)',
|
|
37
|
+
SRadioButton: 'SRadioGroup — 그룹 간격이 -1px(테두리 겹침)이라 직접 만들지 않습니다',
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
/** gap 유틸리티에서 px 값을 뽑는다. 토큰 var() 참조면 null(검사 제외) */
|
|
@@ -43,7 +43,7 @@ function readGap(tokens) {
|
|
|
43
43
|
const util = stripModifiers(stripVariants(token));
|
|
44
44
|
const m = util.match(/^gap-(.+)$/);
|
|
45
45
|
if (!m) continue;
|
|
46
|
-
if (m[1].startsWith(
|
|
46
|
+
if (m[1].startsWith('[')) return { skip: true }; // gap-[var(--...)] 등
|
|
47
47
|
// 간격 스케일은 `gap-sd-8` 형태다. 접두 없는 `gap-8` 은 Tailwind 기본 스케일이라
|
|
48
48
|
// 값이 다르므로(32px) no-off-scale-spacing 이 따로 잡는다 — 여기선 다루지 않는다.
|
|
49
49
|
const scaled = /^sd-(\d+)$/.exec(m[1]);
|
|
@@ -55,21 +55,19 @@ function readGap(tokens) {
|
|
|
55
55
|
/** JSX 자식 중 컴포넌트 엘리먼트만 (공백 텍스트 제외) */
|
|
56
56
|
function elementChildren(node) {
|
|
57
57
|
return (node.children ?? []).filter(
|
|
58
|
-
|
|
59
|
-
ch.type === "JSXElement" &&
|
|
60
|
-
ch.openingElement.name.type === "JSXIdentifier",
|
|
58
|
+
ch => ch.type === 'JSXElement' && ch.openingElement.name.type === 'JSXIdentifier',
|
|
61
59
|
);
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
const nameOf =
|
|
62
|
+
const nameOf = el => el.openingElement.name.name;
|
|
65
63
|
|
|
66
64
|
/** 자식들의 size prop 중 가장 먼저 나오는 리터럴 값 */
|
|
67
65
|
function sizeOf(children) {
|
|
68
66
|
for (const ch of children) {
|
|
69
67
|
const attr = ch.openingElement.attributes.find(
|
|
70
|
-
|
|
68
|
+
a => a.type === 'JSXAttribute' && a.name?.name === 'size',
|
|
71
69
|
);
|
|
72
|
-
if (attr?.value?.type ===
|
|
70
|
+
if (attr?.value?.type === 'Literal' && typeof attr.value.value === 'string') {
|
|
73
71
|
return attr.value.value;
|
|
74
72
|
}
|
|
75
73
|
}
|
|
@@ -78,33 +76,33 @@ function sizeOf(children) {
|
|
|
78
76
|
|
|
79
77
|
/** spec(number | {default, bySize}) 을 size 로 해석 */
|
|
80
78
|
function resolve(spec, size) {
|
|
81
|
-
if (typeof spec ===
|
|
79
|
+
if (typeof spec === 'number') return spec;
|
|
82
80
|
if (!spec) return undefined;
|
|
83
81
|
return (size && spec.bySize?.[size]) ?? spec.default;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
export default {
|
|
87
85
|
meta: {
|
|
88
|
-
type:
|
|
86
|
+
type: 'problem',
|
|
89
87
|
docs: {
|
|
90
|
-
description:
|
|
88
|
+
description: '같은 컴포넌트를 나열할 때 컴포넌트별 그룹 간격 사용',
|
|
91
89
|
},
|
|
92
90
|
schema: [
|
|
93
91
|
{
|
|
94
|
-
type:
|
|
92
|
+
type: 'object',
|
|
95
93
|
properties: {
|
|
96
|
-
ignore: { type:
|
|
94
|
+
ignore: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
97
95
|
},
|
|
98
96
|
additionalProperties: false,
|
|
99
97
|
},
|
|
100
98
|
],
|
|
101
99
|
messages: {
|
|
102
100
|
wrongGap:
|
|
103
|
-
|
|
101
|
+
'{{component}} 를 {{dirLabel}} 배열할 때 간격은 `gap-sd-{{expected}}` 입니다{{actualLabel}}. (AGENTS.md §2-2)',
|
|
104
102
|
useGroupComponent:
|
|
105
|
-
|
|
103
|
+
'{{component}} 를 직접 flex 로 감싸지 마세요 — {{suggestion}}. (AGENTS.md §2-2)',
|
|
106
104
|
},
|
|
107
|
-
fixable:
|
|
105
|
+
fixable: 'code',
|
|
108
106
|
},
|
|
109
107
|
|
|
110
108
|
create(context) {
|
|
@@ -113,7 +111,8 @@ export default {
|
|
|
113
111
|
return {
|
|
114
112
|
JSXElement(node) {
|
|
115
113
|
const classAttr = node.openingElement.attributes.find(
|
|
116
|
-
|
|
114
|
+
a =>
|
|
115
|
+
a.type === 'JSXAttribute' && (a.name?.name === 'className' || a.name?.name === 'class'),
|
|
117
116
|
);
|
|
118
117
|
if (!classAttr) return;
|
|
119
118
|
|
|
@@ -121,23 +120,24 @@ export default {
|
|
|
121
120
|
const utils = tokens.map(({ token }) => stripModifiers(stripVariants(token)));
|
|
122
121
|
|
|
123
122
|
// flex 컨테이너가 아니면 대상이 아니다
|
|
124
|
-
if (!utils.includes(
|
|
123
|
+
if (!utils.includes('flex') && !utils.includes('inline-flex')) return;
|
|
125
124
|
// 양끝 분리는 gap 규칙 대상이 아니다 (하단 버튼 배치 등)
|
|
126
|
-
if (utils.some(
|
|
125
|
+
if (utils.some(u => u.startsWith('justify-between') || u.startsWith('justify-around')))
|
|
126
|
+
return;
|
|
127
127
|
|
|
128
128
|
const children = elementChildren(node);
|
|
129
129
|
if (children.length < 2) return;
|
|
130
130
|
|
|
131
131
|
// 같은 컴포넌트로만 이루어진 그룹만 판단한다
|
|
132
132
|
const first = nameOf(children[0]);
|
|
133
|
-
if (!children.every(
|
|
133
|
+
if (!children.every(ch => nameOf(ch) === first)) return;
|
|
134
134
|
if (ignore.has(first)) return;
|
|
135
135
|
|
|
136
136
|
// 전용 그룹 컴포넌트가 있으면 그쪽을 권한다
|
|
137
137
|
if (HAS_GROUP_COMPONENT[first]) {
|
|
138
138
|
context.report({
|
|
139
139
|
node: node.openingElement,
|
|
140
|
-
messageId:
|
|
140
|
+
messageId: 'useGroupComponent',
|
|
141
141
|
data: { component: first, suggestion: HAS_GROUP_COMPONENT[first] },
|
|
142
142
|
});
|
|
143
143
|
return;
|
|
@@ -146,7 +146,7 @@ export default {
|
|
|
146
146
|
const spec = GROUP_GAP[first];
|
|
147
147
|
if (!spec) return;
|
|
148
148
|
|
|
149
|
-
const vertical = utils.includes(
|
|
149
|
+
const vertical = utils.includes('flex-col') || utils.includes('flex-col-reverse');
|
|
150
150
|
const dirSpec = spec.both ?? (vertical ? spec.vertical : spec.horizontal);
|
|
151
151
|
const expected = resolve(dirSpec, sizeOf(children));
|
|
152
152
|
if (expected == null) return;
|
|
@@ -157,24 +157,26 @@ export default {
|
|
|
157
157
|
|
|
158
158
|
context.report({
|
|
159
159
|
node: node.openingElement,
|
|
160
|
-
messageId:
|
|
160
|
+
messageId: 'wrongGap',
|
|
161
161
|
data: {
|
|
162
162
|
component: first,
|
|
163
|
-
dirLabel: spec.both ?
|
|
163
|
+
dirLabel: spec.both ? '' : vertical ? '세로로' : '가로로',
|
|
164
164
|
expected: String(expected),
|
|
165
|
-
actualLabel:
|
|
165
|
+
actualLabel:
|
|
166
|
+
gap.value == null ? ' (지정되지 않았습니다)' : ` — 현재 gap-sd-${gap.value}`,
|
|
166
167
|
},
|
|
167
168
|
fix(fixer) {
|
|
168
169
|
// 기존 gap 유틸리티가 리터럴 문자열일 때만 안전하게 교체한다
|
|
169
170
|
if (gap.value == null) return null;
|
|
170
171
|
const literal = classAttr.value;
|
|
171
172
|
const raw =
|
|
172
|
-
literal?.type ===
|
|
173
|
+
literal?.type === 'Literal'
|
|
173
174
|
? literal
|
|
174
|
-
: literal?.type ===
|
|
175
|
+
: literal?.type === 'JSXExpressionContainer' &&
|
|
176
|
+
literal.expression.type === 'Literal'
|
|
175
177
|
? literal.expression
|
|
176
178
|
: null;
|
|
177
|
-
if (!raw || typeof raw.value !==
|
|
179
|
+
if (!raw || typeof raw.value !== 'string') return null;
|
|
178
180
|
const next = raw.value.replace(
|
|
179
181
|
new RegExp(`(^|\\s)gap-sd-${gap.value}(?=\\s|$)`),
|
|
180
182
|
`$1gap-sd-${expected}`,
|
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
*
|
|
17
17
|
* AGENTS.md §3-6.
|
|
18
18
|
*/
|
|
19
|
-
import { collectClassTokens, stripVariants, stripModifiers } from
|
|
19
|
+
import { collectClassTokens, stripVariants, stripModifiers } from '../lib/class-names.mjs';
|
|
20
20
|
|
|
21
21
|
/** 높이를 정하는 유틸리티인가 — `h-auto` 만 예외(기본값과 같아 stretch 가 살아 있다) */
|
|
22
22
|
function heightUtil(util) {
|
|
23
23
|
const m = /^h-(.+)$/.exec(util);
|
|
24
|
-
if (!m || m[1] ===
|
|
24
|
+
if (!m || m[1] === 'auto') return null;
|
|
25
25
|
return m[1];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** 교차축 정렬을 이미 직접 지정했는가 */
|
|
29
29
|
function alignsSelf(util) {
|
|
30
|
-
return /^self-/.test(util) || util ===
|
|
30
|
+
return /^self-/.test(util) || util === 'my-auto' || util === 'mt-auto' || util === 'mb-auto';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -41,45 +41,45 @@ function toHeightProp(value) {
|
|
|
41
41
|
if (scaled) return `{${scaled[1]}}`;
|
|
42
42
|
const arbitrary = /^\[(.+)\]$/.exec(value);
|
|
43
43
|
if (!arbitrary) return null;
|
|
44
|
-
const inner = arbitrary[1].replace(/_/g,
|
|
44
|
+
const inner = arbitrary[1].replace(/_/g, ' ');
|
|
45
45
|
const px = /^(\d+(?:\.\d+)?)px$/.exec(inner);
|
|
46
46
|
if (px) return `{${px[1]}}`;
|
|
47
47
|
if (inner.includes('"')) return null;
|
|
48
48
|
return `"${inner}"`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const isClassAttr =
|
|
52
|
-
a.type ===
|
|
51
|
+
const isClassAttr = a =>
|
|
52
|
+
a.type === 'JSXAttribute' && (a.name?.name === 'className' || a.name?.name === 'class');
|
|
53
53
|
|
|
54
54
|
export default {
|
|
55
55
|
meta: {
|
|
56
|
-
type:
|
|
56
|
+
type: 'problem',
|
|
57
57
|
docs: {
|
|
58
|
-
description:
|
|
58
|
+
description: '세로 SDivider 의 높이는 className 이 아니라 height prop 으로 지정',
|
|
59
59
|
},
|
|
60
60
|
schema: [],
|
|
61
61
|
messages: {
|
|
62
62
|
useHeightProp:
|
|
63
|
-
|
|
63
|
+
'세로 SDivider 의 높이는 `height` prop 으로 주세요{{suggestion}} — `className` 으로 주면 `align-self: stretch` 가 무효가 되어 선이 줄 맨 위에 붙습니다. (AGENTS.md §3-6)',
|
|
64
64
|
},
|
|
65
|
-
fixable:
|
|
65
|
+
fixable: 'code',
|
|
66
66
|
},
|
|
67
67
|
|
|
68
68
|
create(context) {
|
|
69
69
|
return {
|
|
70
70
|
JSXOpeningElement(node) {
|
|
71
|
-
if (node.name.type !==
|
|
71
|
+
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'SDivider') return;
|
|
72
72
|
|
|
73
73
|
const attrs = node.attributes;
|
|
74
|
-
const named =
|
|
74
|
+
const named = n => attrs.find(a => a.type === 'JSXAttribute' && a.name?.name === n);
|
|
75
75
|
|
|
76
76
|
// 세로 분할선만 대상. `vertical={expr}` 처럼 값이 정적이지 않으면 판단하지 않는다.
|
|
77
|
-
const vertical = named(
|
|
77
|
+
const vertical = named('vertical');
|
|
78
78
|
if (!vertical) return;
|
|
79
79
|
if (vertical.value !== null && vertical.value?.expression?.value !== true) return;
|
|
80
80
|
|
|
81
81
|
// 이미 height prop 을 쓰고 있으면 컴포넌트가 정렬까지 맡는다
|
|
82
|
-
if (named(
|
|
82
|
+
if (named('height')) return;
|
|
83
83
|
|
|
84
84
|
const classAttr = attrs.find(isClassAttr);
|
|
85
85
|
if (!classAttr) return;
|
|
@@ -98,29 +98,29 @@ export default {
|
|
|
98
98
|
|
|
99
99
|
context.report({
|
|
100
100
|
node,
|
|
101
|
-
messageId:
|
|
102
|
-
data: { suggestion: propValue ? ` (\`height=${propValue}\`)` :
|
|
101
|
+
messageId: 'useHeightProp',
|
|
102
|
+
data: { suggestion: propValue ? ` (\`height=${propValue}\`)` : '' },
|
|
103
103
|
fix(fixer) {
|
|
104
104
|
if (!propValue) return null;
|
|
105
105
|
// className 이 통짜 문자열일 때만 안전하게 손댄다 (cn()·템플릿 리터럴은 제외)
|
|
106
106
|
const raw =
|
|
107
|
-
classAttr.value?.type ===
|
|
107
|
+
classAttr.value?.type === 'Literal'
|
|
108
108
|
? classAttr.value
|
|
109
|
-
: classAttr.value?.type ===
|
|
110
|
-
classAttr.value.expression.type ===
|
|
109
|
+
: classAttr.value?.type === 'JSXExpressionContainer' &&
|
|
110
|
+
classAttr.value.expression.type === 'Literal'
|
|
111
111
|
? classAttr.value.expression
|
|
112
112
|
: null;
|
|
113
|
-
if (!raw || typeof raw.value !==
|
|
113
|
+
if (!raw || typeof raw.value !== 'string') return null;
|
|
114
114
|
|
|
115
115
|
const rest = raw.value
|
|
116
116
|
.split(/\s+/)
|
|
117
117
|
.filter(Boolean)
|
|
118
|
-
.filter(
|
|
118
|
+
.filter(t => t !== `h-${height}`);
|
|
119
119
|
if (rest.length === raw.value.split(/\s+/).filter(Boolean).length) return null;
|
|
120
120
|
|
|
121
121
|
const fixes = [fixer.insertTextAfter(vertical, ` height=${propValue}`)];
|
|
122
122
|
if (rest.length > 0) {
|
|
123
|
-
fixes.push(fixer.replaceText(raw, `"${rest.join(
|
|
123
|
+
fixes.push(fixer.replaceText(raw, `"${rest.join(' ')}"`));
|
|
124
124
|
} else {
|
|
125
125
|
// 속성만 지우면 공백이 두 칸 남는다 — 앞 공백까지 함께 지운다
|
|
126
126
|
const src = context.sourceCode ?? context.getSourceCode();
|