sellmate-design-system-react 3.0.0 → 3.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/AGENTS.md +695 -0
- package/README.md +106 -0
- package/bin/sellmate-ds.mjs +337 -0
- package/dist/components/SActionModal/README.md +41 -0
- package/dist/components/SBadge/README.md +25 -0
- package/dist/components/SBarcodeInput/README.md +64 -0
- package/dist/components/SButton/README.md +49 -0
- package/dist/components/SCalendar/README.md +42 -0
- package/dist/components/SCallout/README.md +29 -0
- package/dist/components/SCard/README.md +12 -0
- package/dist/components/SCheckbox/README.md +44 -0
- package/dist/components/SChip/README.md +57 -0
- package/dist/components/SChipInput/README.md +77 -0
- package/dist/components/SCircleProgress/README.md +35 -0
- package/dist/components/SConfirmModal/README.md +61 -0
- package/dist/components/SDatePicker/README.md +62 -0
- package/dist/components/SDateRangePicker/README.md +64 -0
- package/dist/components/SDivider/README.md +25 -0
- package/dist/components/SDraggableItem/README.md +40 -0
- package/dist/components/SDropdownButton/README.md +50 -0
- package/dist/components/SExpansionItem/README.md +40 -0
- package/dist/components/SField/README.md +91 -0
- package/dist/components/SFilePicker/README.md +72 -0
- package/dist/components/SForm/README.md +31 -0
- package/dist/components/SGhostButton/README.md +76 -0
- package/dist/components/SGnb/README.md +56 -0
- package/dist/components/SGuide/README.md +34 -0
- package/dist/components/SIcon/README.md +91 -0
- package/dist/components/SInput/README.md +66 -0
- package/dist/components/SKeyValueTable/README.md +59 -0
- package/dist/components/SLayout/README.md +42 -0
- package/dist/components/SLinearProgress/README.md +17 -0
- package/dist/components/SList/README.md +14 -0
- package/dist/components/SListItem/README.md +19 -0
- package/dist/components/SLoadingContainer/README.md +29 -0
- package/dist/components/SLoadingModal/README.md +52 -0
- package/dist/components/SModal/README.md +192 -0
- package/dist/components/SModalContainer/README.md +49 -0
- package/dist/components/SNumberInput/README.md +74 -0
- package/dist/components/SPage/README.md +25 -0
- package/dist/components/SPagination/README.md +40 -0
- package/dist/components/SPopover/README.md +54 -0
- package/dist/components/SPopup/README.md +38 -0
- package/dist/components/SPortal/README.md +51 -0
- package/dist/components/SRadio/README.md +57 -0
- package/dist/components/SRadioButton/README.md +24 -0
- package/dist/components/SScrollArea/README.md +16 -0
- package/dist/components/SSectionHeaderCard/README.md +51 -0
- package/dist/components/SSelect/README.md +79 -0
- package/dist/components/SStepper/README.md +30 -0
- package/dist/components/SSwitch/README.md +24 -0
- package/dist/components/STable/README.md +82 -0
- package/dist/components/STableBar/README.md +28 -0
- package/dist/components/STabs/README.md +37 -0
- package/dist/components/STag/README.md +44 -0
- package/dist/components/STextLink/README.md +48 -0
- package/dist/components/STextarea/README.md +60 -0
- package/dist/components/STimePicker/README.md +65 -0
- package/dist/components/STimeRangePicker/README.md +63 -0
- package/dist/components/SToast/README.md +70 -0
- package/dist/components/SToggle/README.md +22 -0
- package/dist/components/STooltip/README.md +57 -0
- package/dist/llms-full.txt +3530 -0
- package/dist/llms.txt +882 -0
- package/dist/styles.css +221 -0
- package/dist/theme.css +8 -0
- package/eslint/index.mjs +69 -0
- package/eslint/lib/class-names.mjs +119 -0
- package/eslint/lib/table-column.mjs +53 -0
- package/eslint/rules/component-group-gap.mjs +186 -0
- package/eslint/rules/no-arbitrary-class.mjs +91 -0
- package/eslint/rules/no-off-scale-spacing.mjs +67 -0
- package/eslint/rules/no-raw-html-control.mjs +117 -0
- package/eslint/rules/prefer-typo-preset.mjs +73 -0
- package/eslint/rules/require-locale-number.mjs +78 -0
- package/eslint/rules/table-numeric-align.mjs +93 -0
- package/eslint/scale.gen.mjs +16 -0
- package/package.json +15 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STableColumn 형태 판별과 "숫자 컬럼" 판정을 공유하는 헬퍼.
|
|
3
|
+
* table-numeric-align · require-locale-number 두 규칙이 함께 쓴다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 오른쪽 정렬 대상 — 숫자가 들어가는 모든 컬럼.
|
|
8
|
+
* 식별자(주문번호·상품코드)도 숫자라 자릿수를 맞춰야 하므로 포함한다.
|
|
9
|
+
*/
|
|
10
|
+
export const NUMERIC_LABEL =
|
|
11
|
+
/금액|가격|단가|판매가|원가|비용|수수료|배송비|할인|합계|총액|잔액|포인트|적립|수량|재고|개수|건수|횟수|비율|퍼센트|평점|점수|무게|용량|길이|면적|번호|코드|순번|순위|나이|연차/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 하이픈 등 서식이 들어간 문자열 — 숫자가 아니므로 정렬·서식 규칙 모두 대상이 아니다.
|
|
15
|
+
* (전화번호 010-1234-5678, 사업자번호 123-45-67890)
|
|
16
|
+
*/
|
|
17
|
+
export const IDENTIFIER_ONLY = /전화|휴대폰|연락처|사업자|주민|계좌|우편/;
|
|
18
|
+
|
|
19
|
+
/** format 본문에 숫자 서식이 있으면 숫자 컬럼으로 본다 */
|
|
20
|
+
export const NUMERIC_FORMAT = /toLocaleString|toFixed|Number\s*\(|parseInt|parseFloat/;
|
|
21
|
+
|
|
22
|
+
/** 객체 리터럴에서 프로퍼티 노드를 이름으로 찾는다 */
|
|
23
|
+
export function prop(objectNode, name) {
|
|
24
|
+
return objectNode.properties.find(
|
|
25
|
+
(p) =>
|
|
26
|
+
p.type === "Property" &&
|
|
27
|
+
!p.computed &&
|
|
28
|
+
((p.key.type === "Identifier" && p.key.name === name) ||
|
|
29
|
+
(p.key.type === "Literal" && p.key.value === name)),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 문자열 리터럴 값 (아니면 undefined) */
|
|
34
|
+
export function literalString(node) {
|
|
35
|
+
return node?.value?.type === "Literal" && typeof node.value.value === "string"
|
|
36
|
+
? node.value.value
|
|
37
|
+
: undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* STableColumn 형태인지 — name·label·field 셋을 모두 가진 객체만 컬럼으로 본다.
|
|
42
|
+
* @returns {{ nameProp, labelProp, label: string } | null}
|
|
43
|
+
*/
|
|
44
|
+
export function asColumn(node) {
|
|
45
|
+
const nameProp = prop(node, "name");
|
|
46
|
+
const labelProp = prop(node, "label");
|
|
47
|
+
if (!nameProp || !labelProp || !prop(node, "field")) return null;
|
|
48
|
+
|
|
49
|
+
const label = literalString(labelProp);
|
|
50
|
+
if (label === undefined) return null; // 동적 label 은 판단하지 않는다
|
|
51
|
+
|
|
52
|
+
return { nameProp, labelProp, label, columnName: literalString(nameProp) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 같은 컴포넌트를 여러 개 나열할 때 컴포넌트별 그룹 간격을 지키게 한다.
|
|
3
|
+
*
|
|
4
|
+
* 일반 요소 사이의 기본값(gap-8)과 달리, 체크박스·라디오는 가로 24 / 세로 8 처럼
|
|
5
|
+
* **배열 방향에 따라 값이 다르다.** 가로 배열에 8 을 쓰면 항목이 붙어 보인다.
|
|
6
|
+
*
|
|
7
|
+
* 오탐을 줄이기 위해 다음은 검사하지 않는다.
|
|
8
|
+
* - `justify-between` 등으로 양끝 분리한 경우 (하단 버튼 배치 규칙 §3-5)
|
|
9
|
+
* - gap 을 토큰 var() 로 직접 참조한 경우
|
|
10
|
+
* - 자식이 2개 미만이거나 여러 종류가 섞인 경우
|
|
11
|
+
*
|
|
12
|
+
* AGENTS.md §2-2.
|
|
13
|
+
*/
|
|
14
|
+
import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 컴포넌트별 기대 간격 (px).
|
|
18
|
+
* horizontal/vertical 이 다르면 각각, 같으면 both 로 둔다.
|
|
19
|
+
* sizes 가 있으면 자식의 size prop 에 따라 값이 갈린다.
|
|
20
|
+
*/
|
|
21
|
+
const GROUP_GAP = {
|
|
22
|
+
SCheckbox: { horizontal: 24, vertical: 8 },
|
|
23
|
+
SRadio: { horizontal: 24, vertical: 8 },
|
|
24
|
+
STextLink: {
|
|
25
|
+
horizontal: { default: 16, bySize: { md: 24, lg: 24 } },
|
|
26
|
+
vertical: 4,
|
|
27
|
+
},
|
|
28
|
+
SGhostButton: { both: 4 },
|
|
29
|
+
SButton: { both: { default: 8, bySize: { lg: 12 } } },
|
|
30
|
+
STag: { both: 8 },
|
|
31
|
+
SToggle: { both: 8 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 이 컴포넌트들은 전용 그룹 컴포넌트가 따로 있다 */
|
|
35
|
+
const HAS_GROUP_COMPONENT = {
|
|
36
|
+
SRadio: "SRadioGroup (direction 으로 간격을 맞춥니다)",
|
|
37
|
+
SRadioButton: "SRadioGroup — 그룹 간격이 -1px(테두리 겹침)이라 직접 만들지 않습니다",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** gap 유틸리티에서 px 값을 뽑는다. 토큰 var() 참조면 null(검사 제외) */
|
|
41
|
+
function readGap(tokens) {
|
|
42
|
+
for (const { token } of tokens) {
|
|
43
|
+
const util = stripModifiers(stripVariants(token));
|
|
44
|
+
const m = util.match(/^gap-(.+)$/);
|
|
45
|
+
if (!m) continue;
|
|
46
|
+
if (m[1].startsWith("[")) return { skip: true }; // gap-[var(--...)] 등
|
|
47
|
+
if (/^\d+$/.test(m[1])) return { value: Number(m[1]) };
|
|
48
|
+
}
|
|
49
|
+
return { value: null }; // gap 자체가 없음
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JSX 자식 중 컴포넌트 엘리먼트만 (공백 텍스트 제외) */
|
|
53
|
+
function elementChildren(node) {
|
|
54
|
+
return (node.children ?? []).filter(
|
|
55
|
+
(ch) =>
|
|
56
|
+
ch.type === "JSXElement" &&
|
|
57
|
+
ch.openingElement.name.type === "JSXIdentifier",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nameOf = (el) => el.openingElement.name.name;
|
|
62
|
+
|
|
63
|
+
/** 자식들의 size prop 중 가장 먼저 나오는 리터럴 값 */
|
|
64
|
+
function sizeOf(children) {
|
|
65
|
+
for (const ch of children) {
|
|
66
|
+
const attr = ch.openingElement.attributes.find(
|
|
67
|
+
(a) => a.type === "JSXAttribute" && a.name?.name === "size",
|
|
68
|
+
);
|
|
69
|
+
if (attr?.value?.type === "Literal" && typeof attr.value.value === "string") {
|
|
70
|
+
return attr.value.value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** spec(number | {default, bySize}) 을 size 로 해석 */
|
|
77
|
+
function resolve(spec, size) {
|
|
78
|
+
if (typeof spec === "number") return spec;
|
|
79
|
+
if (!spec) return undefined;
|
|
80
|
+
return (size && spec.bySize?.[size]) ?? spec.default;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default {
|
|
84
|
+
meta: {
|
|
85
|
+
type: "problem",
|
|
86
|
+
docs: {
|
|
87
|
+
description: "같은 컴포넌트를 나열할 때 컴포넌트별 그룹 간격 사용",
|
|
88
|
+
},
|
|
89
|
+
schema: [
|
|
90
|
+
{
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
ignore: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
94
|
+
},
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
messages: {
|
|
99
|
+
wrongGap:
|
|
100
|
+
"{{component}} 를 {{dirLabel}} 배열할 때 간격은 `gap-{{expected}}` 입니다{{actualLabel}}. (AGENTS.md §2-2)",
|
|
101
|
+
useGroupComponent:
|
|
102
|
+
"{{component}} 를 직접 flex 로 감싸지 마세요 — {{suggestion}}. (AGENTS.md §2-2)",
|
|
103
|
+
},
|
|
104
|
+
fixable: "code",
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
create(context) {
|
|
108
|
+
const ignore = new Set(context.options[0]?.ignore ?? []);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
JSXElement(node) {
|
|
112
|
+
const classAttr = node.openingElement.attributes.find(
|
|
113
|
+
(a) => a.type === "JSXAttribute" && (a.name?.name === "className" || a.name?.name === "class"),
|
|
114
|
+
);
|
|
115
|
+
if (!classAttr) return;
|
|
116
|
+
|
|
117
|
+
const tokens = collectClassTokens(classAttr);
|
|
118
|
+
const utils = tokens.map(({ token }) => stripModifiers(stripVariants(token)));
|
|
119
|
+
|
|
120
|
+
// flex 컨테이너가 아니면 대상이 아니다
|
|
121
|
+
if (!utils.includes("flex") && !utils.includes("inline-flex")) return;
|
|
122
|
+
// 양끝 분리는 gap 규칙 대상이 아니다 (하단 버튼 배치 등)
|
|
123
|
+
if (utils.some((u) => u.startsWith("justify-between") || u.startsWith("justify-around"))) return;
|
|
124
|
+
|
|
125
|
+
const children = elementChildren(node);
|
|
126
|
+
if (children.length < 2) return;
|
|
127
|
+
|
|
128
|
+
// 같은 컴포넌트로만 이루어진 그룹만 판단한다
|
|
129
|
+
const first = nameOf(children[0]);
|
|
130
|
+
if (!children.every((ch) => nameOf(ch) === first)) return;
|
|
131
|
+
if (ignore.has(first)) return;
|
|
132
|
+
|
|
133
|
+
// 전용 그룹 컴포넌트가 있으면 그쪽을 권한다
|
|
134
|
+
if (HAS_GROUP_COMPONENT[first]) {
|
|
135
|
+
context.report({
|
|
136
|
+
node: node.openingElement,
|
|
137
|
+
messageId: "useGroupComponent",
|
|
138
|
+
data: { component: first, suggestion: HAS_GROUP_COMPONENT[first] },
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const spec = GROUP_GAP[first];
|
|
144
|
+
if (!spec) return;
|
|
145
|
+
|
|
146
|
+
const vertical = utils.includes("flex-col") || utils.includes("flex-col-reverse");
|
|
147
|
+
const dirSpec = spec.both ?? (vertical ? spec.vertical : spec.horizontal);
|
|
148
|
+
const expected = resolve(dirSpec, sizeOf(children));
|
|
149
|
+
if (expected == null) return;
|
|
150
|
+
|
|
151
|
+
const gap = readGap(tokens);
|
|
152
|
+
if (gap.skip) return;
|
|
153
|
+
if (gap.value === expected) return;
|
|
154
|
+
|
|
155
|
+
context.report({
|
|
156
|
+
node: node.openingElement,
|
|
157
|
+
messageId: "wrongGap",
|
|
158
|
+
data: {
|
|
159
|
+
component: first,
|
|
160
|
+
dirLabel: spec.both ? "" : vertical ? "세로로" : "가로로",
|
|
161
|
+
expected: String(expected),
|
|
162
|
+
actualLabel: gap.value == null ? " (지정되지 않았습니다)" : ` — 현재 gap-${gap.value}`,
|
|
163
|
+
},
|
|
164
|
+
fix(fixer) {
|
|
165
|
+
// 기존 gap 유틸리티가 리터럴 문자열일 때만 안전하게 교체한다
|
|
166
|
+
if (gap.value == null) return null;
|
|
167
|
+
const literal = classAttr.value;
|
|
168
|
+
const raw =
|
|
169
|
+
literal?.type === "Literal"
|
|
170
|
+
? literal
|
|
171
|
+
: literal?.type === "JSXExpressionContainer" && literal.expression.type === "Literal"
|
|
172
|
+
? literal.expression
|
|
173
|
+
: null;
|
|
174
|
+
if (!raw || typeof raw.value !== "string") return null;
|
|
175
|
+
const next = raw.value.replace(
|
|
176
|
+
new RegExp(`(^|\\s)gap-${gap.value}(?=\\s|$)`),
|
|
177
|
+
`$1gap-${expected}`,
|
|
178
|
+
);
|
|
179
|
+
if (next === raw.value) return null;
|
|
180
|
+
return fixer.replaceText(raw, `"${next}"`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **디자인 토큰이 있는 속성**에 임의 값을 쓰는 것을 막는다 — `text-[14px]`, `bg-[#eee]` 등.
|
|
3
|
+
*
|
|
4
|
+
* 반대로, 디자인 시스템이 토큰화하지 않는 **앱 고유 레이아웃 치수**(`w-[280px]`,
|
|
5
|
+
* `max-w-[1200px]`, `grid-cols-[200px_1fr]`)는 정당한 사용이므로 막지 않는다.
|
|
6
|
+
* 토큰을 var() 로 참조하는 형태(`h-[var(--sys-size-...)]`)도 허용한다.
|
|
7
|
+
*
|
|
8
|
+
* AGENTS.md §1-2.
|
|
9
|
+
*/
|
|
10
|
+
import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 토큰이 존재하는 속성 접두어 — 여기에 임의 값을 쓰면 디자인 시스템을 우회하는 것이다.
|
|
14
|
+
* 색·타이포·간격·모서리 4계열.
|
|
15
|
+
*/
|
|
16
|
+
const TOKEN_BACKED = [
|
|
17
|
+
// 색 (text- 는 색·폰트크기 양쪽 모두 토큰이 있어 어느 쪽이든 대상)
|
|
18
|
+
"text", "bg", "border", "outline", "ring", "shadow", "fill", "stroke",
|
|
19
|
+
"divide", "accent", "caret", "decoration", "from", "via", "to",
|
|
20
|
+
// 타이포
|
|
21
|
+
"font", "leading", "tracking",
|
|
22
|
+
// 간격
|
|
23
|
+
"gap", "gap-x", "gap-y", "space-x", "space-y",
|
|
24
|
+
"p", "px", "py", "pt", "pr", "pb", "pl", "ps", "pe",
|
|
25
|
+
"m", "mx", "my", "mt", "mr", "mb", "ml", "ms", "me",
|
|
26
|
+
// 모서리
|
|
27
|
+
"rounded", "rounded-t", "rounded-r", "rounded-b", "rounded-l",
|
|
28
|
+
"rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** 대괄호 안이 토큰 참조인지 — `var(--x)` 또는 `length:var(--x)` 같은 데이터타입 접두어 허용 */
|
|
32
|
+
function isTokenReference(inner) {
|
|
33
|
+
const body = inner.includes(":") ? inner.slice(inner.indexOf(":") + 1) : inner;
|
|
34
|
+
return /^var\(--[\w-]+\)$/.test(body.trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `bg-[#eee]` → `bg` 처럼 임의 값 앞의 유틸리티 접두어를 뽑는다 */
|
|
38
|
+
function prefixOf(util) {
|
|
39
|
+
const idx = util.indexOf("[");
|
|
40
|
+
return idx <= 0 ? "" : util.slice(0, idx - 1); // 대괄호 바로 앞의 `-` 제외
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
meta: {
|
|
45
|
+
type: "problem",
|
|
46
|
+
docs: {
|
|
47
|
+
description: "색·타이포·간격·모서리에 임의 값 금지 — 토큰 유틸리티 또는 var(--token) 참조 사용",
|
|
48
|
+
},
|
|
49
|
+
schema: [
|
|
50
|
+
{
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
/** 예외로 허용할 클래스 (완전 일치) */
|
|
54
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
55
|
+
/** 검사 대상에서 뺄 유틸리티 접두어 (예: ["shadow"]) */
|
|
56
|
+
ignorePrefixes: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
57
|
+
},
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
messages: {
|
|
62
|
+
arbitrary:
|
|
63
|
+
"`{{token}}` — {{prefix}} 는 디자인 토큰이 있는 속성이라 임의 값을 쓸 수 없습니다. 토큰 유틸리티를 쓰거나 var(--token) 으로 참조하세요. (AGENTS.md §1-2)",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
create(context) {
|
|
68
|
+
const allow = new Set(context.options[0]?.allow ?? []);
|
|
69
|
+
const ignored = new Set(context.options[0]?.ignorePrefixes ?? []);
|
|
70
|
+
const targets = TOKEN_BACKED.filter((p) => !ignored.has(p));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
JSXAttribute(node) {
|
|
74
|
+
for (const { token, node: valueNode } of collectClassTokens(node)) {
|
|
75
|
+
if (allow.has(token)) continue;
|
|
76
|
+
|
|
77
|
+
const util = stripModifiers(stripVariants(token));
|
|
78
|
+
const match = util.match(/\[([^\]]*)\]/);
|
|
79
|
+
if (!match) continue;
|
|
80
|
+
if (isTokenReference(match[1])) continue;
|
|
81
|
+
|
|
82
|
+
// 토큰이 없는 속성(w·max-w·grid-cols·top …)의 임의 값은 앱 고유 치수이므로 허용
|
|
83
|
+
const prefix = prefixOf(util);
|
|
84
|
+
if (!targets.includes(prefix)) continue;
|
|
85
|
+
|
|
86
|
+
context.report({ node: valueNode, messageId: "arbitrary", data: { token, prefix } });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 간격 유틸리티가 토큰 스케일 밖의 값을 쓰는 것을 막는다 — `gap-13`, `p-15` 등.
|
|
3
|
+
*
|
|
4
|
+
* Tailwind v4 는 `--spacing-*` 테마 키에 있는 값만 유틸리티로 생성하므로,
|
|
5
|
+
* 스케일 밖 숫자는 **에러 없이 조용히 아무 스타일도 적용되지 않는다.**
|
|
6
|
+
* 눈으로는 "간격이 왜 안 먹지" 로만 보이기 때문에 린트로 잡는다.
|
|
7
|
+
*
|
|
8
|
+
* AGENTS.md §2-2.
|
|
9
|
+
*/
|
|
10
|
+
import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
|
|
11
|
+
import { SPACING } from "../scale.gen.mjs";
|
|
12
|
+
|
|
13
|
+
/** 값이 순수하게 간격 스케일에서 오는 유틸리티 접두어만 대상으로 한다 */
|
|
14
|
+
const SPACING_PREFIXES = [
|
|
15
|
+
"gap-x", "gap-y", "gap",
|
|
16
|
+
"space-x", "space-y",
|
|
17
|
+
"px", "py", "pt", "pr", "pb", "pl", "ps", "pe", "p",
|
|
18
|
+
"mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "m",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const ALLOWED = new Set([0, ...SPACING]);
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: {
|
|
27
|
+
description: "간격 유틸리티는 토큰 스케일 값만 사용",
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: {
|
|
31
|
+
offScale:
|
|
32
|
+
"`{{token}}` 의 {{value}} 는 간격 스케일에 없어 스타일이 적용되지 않습니다. 가까운 값: {{nearest}}. (AGENTS.md §2-2)",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create(context) {
|
|
37
|
+
return {
|
|
38
|
+
JSXAttribute(node) {
|
|
39
|
+
for (const { token, node: valueNode } of collectClassTokens(node)) {
|
|
40
|
+
const util = stripModifiers(stripVariants(token));
|
|
41
|
+
|
|
42
|
+
const prefix = SPACING_PREFIXES.find((p) => util.startsWith(`${p}-`));
|
|
43
|
+
if (!prefix) continue;
|
|
44
|
+
|
|
45
|
+
const raw = util.slice(prefix.length + 1);
|
|
46
|
+
// 숫자가 아닌 값(auto, px, full, [var(--x)] 등)은 이 규칙 대상이 아니다
|
|
47
|
+
if (!/^\d+$/.test(raw)) continue;
|
|
48
|
+
|
|
49
|
+
const value = Number(raw);
|
|
50
|
+
if (ALLOWED.has(value)) continue;
|
|
51
|
+
|
|
52
|
+
const nearest = [...ALLOWED]
|
|
53
|
+
.sort((a, b) => Math.abs(a - value) - Math.abs(b - value))
|
|
54
|
+
.slice(0, 2)
|
|
55
|
+
.sort((a, b) => a - b)
|
|
56
|
+
.join(" / ");
|
|
57
|
+
|
|
58
|
+
context.report({
|
|
59
|
+
node: valueNode,
|
|
60
|
+
messageId: "offScale",
|
|
61
|
+
data: { token, value: String(value), nearest },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 디자인 시스템에 대응 컴포넌트가 있는 생 HTML 요소 사용을 막는다.
|
|
3
|
+
* AGENTS.md §1-1 과 같은 목록을 강제한다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 기본 대상 — 대응 컴포넌트가 명확해 오탐 여지가 거의 없는 요소.
|
|
8
|
+
*/
|
|
9
|
+
const REPLACEMENTS = {
|
|
10
|
+
button: "SButton / SGhostButton / SDropdownButton / STextLink",
|
|
11
|
+
input: "SInput / SNumberInput / SCheckbox / SRadio / SFilePicker",
|
|
12
|
+
select: "SSelect",
|
|
13
|
+
textarea: "STextarea",
|
|
14
|
+
table: "STable / SKeyValueTable",
|
|
15
|
+
form: "SForm",
|
|
16
|
+
dialog: "SModal.create(...) / SModal.confirm(...) / SPopup",
|
|
17
|
+
hr: "SDivider",
|
|
18
|
+
details: "SExpansionItem",
|
|
19
|
+
summary: "SExpansionItem",
|
|
20
|
+
progress: "SLinearProgress / SCircleProgress",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* strict 옵션에서만 검사하는 요소 — 정당한 사용처가 함께 있어 기본에서는 뺀다.
|
|
25
|
+
* - svg : 로고·일러스트·차트
|
|
26
|
+
* - ul/li : 본문 안의 시맨틱 목록, 브레드크럼
|
|
27
|
+
* - label : DS 밖 컨트롤의 접근성 레이블
|
|
28
|
+
*/
|
|
29
|
+
const STRICT_REPLACEMENTS = {
|
|
30
|
+
ul: "SList + SListItem",
|
|
31
|
+
ol: "SList + SListItem",
|
|
32
|
+
li: "SListItem",
|
|
33
|
+
svg: "SIcon",
|
|
34
|
+
label: "SField 의 label prop",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** 시각 요소가 없어 대체 컴포넌트가 없는 input 타입 */
|
|
38
|
+
const EXEMPT_INPUT_TYPES = new Set(["hidden"]);
|
|
39
|
+
|
|
40
|
+
/** JSX 속성에서 리터럴 문자열 값을 읽는다 (동적 값이면 undefined) */
|
|
41
|
+
function literalAttr(node, name) {
|
|
42
|
+
const attr = node.attributes.find(
|
|
43
|
+
(a) => a.type === "JSXAttribute" && a.name?.name === name,
|
|
44
|
+
);
|
|
45
|
+
const v = attr?.value;
|
|
46
|
+
if (v?.type === "Literal" && typeof v.value === "string") return v.value;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 전역 함수 → 권장 대체 */
|
|
51
|
+
const GLOBAL_CALLS = {
|
|
52
|
+
alert: "SToast / SModal.confirm(...)",
|
|
53
|
+
confirm: "SModal.confirm(...)",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default {
|
|
57
|
+
meta: {
|
|
58
|
+
type: "problem",
|
|
59
|
+
docs: {
|
|
60
|
+
description: "디자인 시스템 컴포넌트가 있는 생 HTML 컨트롤 사용 금지",
|
|
61
|
+
},
|
|
62
|
+
schema: [
|
|
63
|
+
{
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
/** 예외로 허용할 요소명 (예: 로고용 svg) */
|
|
67
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
68
|
+
/** ul·ol·li·svg·label 까지 검사 (정당한 사용처가 있어 기본은 off) */
|
|
69
|
+
strict: { type: "boolean" },
|
|
70
|
+
},
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
messages: {
|
|
75
|
+
rawElement: "<{{name}}> 대신 {{replacement}} 를 사용하세요. (AGENTS.md §1-1)",
|
|
76
|
+
rawCall: "{{name}}() 대신 {{replacement}} 를 사용하세요. (AGENTS.md §1-1)",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
create(context) {
|
|
81
|
+
const options = context.options[0] ?? {};
|
|
82
|
+
const allow = new Set(options.allow ?? []);
|
|
83
|
+
const replacements = options.strict
|
|
84
|
+
? { ...REPLACEMENTS, ...STRICT_REPLACEMENTS }
|
|
85
|
+
: REPLACEMENTS;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
JSXOpeningElement(node) {
|
|
89
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
90
|
+
const name = node.name.name;
|
|
91
|
+
// 컴포넌트(대문자 시작)는 대상이 아니다
|
|
92
|
+
if (name[0] !== name[0].toLowerCase()) return;
|
|
93
|
+
if (allow.has(name)) return;
|
|
94
|
+
|
|
95
|
+
const replacement = replacements[name];
|
|
96
|
+
if (!replacement) return;
|
|
97
|
+
|
|
98
|
+
// <input type="hidden"> 처럼 시각 요소가 없는 경우는 대체 컴포넌트가 없다
|
|
99
|
+
if (name === "input" && EXEMPT_INPUT_TYPES.has(literalAttr(node, "type"))) return;
|
|
100
|
+
|
|
101
|
+
context.report({ node: node.name, messageId: "rawElement", data: { name, replacement } });
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
CallExpression(node) {
|
|
105
|
+
if (node.callee.type !== "Identifier") return;
|
|
106
|
+
const name = node.callee.name;
|
|
107
|
+
const replacement = GLOBAL_CALLS[name];
|
|
108
|
+
if (!replacement) return;
|
|
109
|
+
// 지역 선언(사용자가 만든 confirm 등)은 제외
|
|
110
|
+
if (context.sourceCode.getScope(node).references.some((r) => r.identifier === node.callee && r.resolved)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
context.report({ node, messageId: "rawCall", data: { name, replacement } });
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 폰트 크기·굵기를 낱개 유틸리티로 조립하는 것을 막고 `typo-*` 프리셋 사용을 유도한다.
|
|
3
|
+
* `text-14 font-bold` 대신 `typo-body-md-bold`.
|
|
4
|
+
*
|
|
5
|
+
* AGENTS.md §1-3 / §2-1.
|
|
6
|
+
*/
|
|
7
|
+
import { collectClassTokens, stripVariants, stripModifiers } from "../lib/class-names.mjs";
|
|
8
|
+
import { TEXT_SIZES, TYPO_PRESETS } from "../scale.gen.mjs";
|
|
9
|
+
|
|
10
|
+
const FONT_WEIGHTS = new Set([
|
|
11
|
+
"font-thin", "font-extralight", "font-light", "font-normal", "font-medium",
|
|
12
|
+
"font-semibold", "font-bold", "font-extrabold", "font-black",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const TEXT_SIZE_SET = new Set(TEXT_SIZES.map(String));
|
|
16
|
+
|
|
17
|
+
/** 자주 쓰는 조합에 대한 추천 프리셋 (없으면 일반 안내) */
|
|
18
|
+
function suggest(size) {
|
|
19
|
+
const byRole = {
|
|
20
|
+
12: "typo-body-sm-*",
|
|
21
|
+
14: "typo-body-md-* / typo-heading-sm",
|
|
22
|
+
16: "typo-body-lg-* / typo-heading-md",
|
|
23
|
+
18: "typo-heading-lg",
|
|
24
|
+
11: "typo-body-xs-default",
|
|
25
|
+
};
|
|
26
|
+
return byRole[size] ?? "typo-*";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
meta: {
|
|
31
|
+
type: "suggestion",
|
|
32
|
+
docs: {
|
|
33
|
+
description: "폰트 크기·굵기 낱개 조합 대신 typo-* 프리셋 사용",
|
|
34
|
+
},
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: {
|
|
37
|
+
fontSize:
|
|
38
|
+
"`{{token}}` 대신 타이포그래피 프리셋을 쓰세요 — {{suggestion}}. (AGENTS.md §2-1)",
|
|
39
|
+
fontWeight:
|
|
40
|
+
"`{{token}}` 을 단독으로 쓰지 말고 굵기가 포함된 typo-* 프리셋을 쓰세요. (AGENTS.md §2-1)",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
create(context) {
|
|
45
|
+
return {
|
|
46
|
+
JSXAttribute(node) {
|
|
47
|
+
const tokens = collectClassTokens(node);
|
|
48
|
+
// 이미 프리셋을 쓰고 있으면 굵기 보조 유틸리티는 문제 삼지 않는다
|
|
49
|
+
const hasPreset = tokens.some(({ token }) =>
|
|
50
|
+
TYPO_PRESETS.includes(stripModifiers(stripVariants(token))),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
for (const { token, node: valueNode } of tokens) {
|
|
54
|
+
const util = stripModifiers(stripVariants(token));
|
|
55
|
+
|
|
56
|
+
const sizeMatch = util.match(/^text-(\d+)$/);
|
|
57
|
+
if (sizeMatch && TEXT_SIZE_SET.has(sizeMatch[1])) {
|
|
58
|
+
context.report({
|
|
59
|
+
node: valueNode,
|
|
60
|
+
messageId: "fontSize",
|
|
61
|
+
data: { token, suggestion: suggest(Number(sizeMatch[1])) },
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!hasPreset && FONT_WEIGHTS.has(util)) {
|
|
67
|
+
context.report({ node: valueNode, messageId: "fontWeight", data: { token } });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 숫자 컬럼은 예외 없이 `toLocaleString()` 으로 세 자리 콤마를 넣도록 강제한다.
|
|
3
|
+
*
|
|
4
|
+
* 전화번호·사업자번호처럼 하이픈이 들어가는 값은 숫자가 아니라 서식이 정해진
|
|
5
|
+
* 문자열이므로 대상에서 뺀다. 그 밖에 예외가 필요하면 `allow` 로 컬럼을 지정한다.
|
|
6
|
+
*
|
|
7
|
+
* AGENTS.md §1-4 / §3-4.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
NUMERIC_LABEL,
|
|
11
|
+
IDENTIFIER_ONLY,
|
|
12
|
+
prop,
|
|
13
|
+
asColumn,
|
|
14
|
+
} from "../lib/table-column.mjs";
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
meta: {
|
|
18
|
+
type: "problem",
|
|
19
|
+
docs: {
|
|
20
|
+
description: "금액·수량 등 숫자 표시에 toLocaleString 사용",
|
|
21
|
+
},
|
|
22
|
+
schema: [
|
|
23
|
+
{
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
/** 검사에서 뺄 컬럼 name */
|
|
27
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
28
|
+
},
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
messages: {
|
|
33
|
+
missingFormat:
|
|
34
|
+
"숫자 컬럼 `{{label}}` 에 format 이 없습니다. `format: (v) => `${Number(v).toLocaleString()}{{unit}}`` 처럼 세 자리 구분을 넣으세요. (AGENTS.md §1-4)",
|
|
35
|
+
missingLocale:
|
|
36
|
+
"숫자 컬럼 `{{label}}` 의 format 이 toLocaleString 을 쓰지 않습니다. 세 자리 구분 없이 표시됩니다. (AGENTS.md §1-4)",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
create(context) {
|
|
41
|
+
const allow = new Set(context.options[0]?.allow ?? []);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
ObjectExpression(node) {
|
|
45
|
+
const column = asColumn(node);
|
|
46
|
+
if (!column) return;
|
|
47
|
+
|
|
48
|
+
const { labelProp, label, columnName } = column;
|
|
49
|
+
if (columnName && allow.has(columnName)) return;
|
|
50
|
+
|
|
51
|
+
// 숫자 컬럼이면 모두 대상 — 하이픈 서식 문자열(전화번호 등)만 뺀다
|
|
52
|
+
if (!NUMERIC_LABEL.test(label) || IDENTIFIER_ONLY.test(label)) return;
|
|
53
|
+
|
|
54
|
+
// render 로 셀을 직접 그리는 경우는 이 규칙이 판단하지 않는다
|
|
55
|
+
if (prop(node, "render")) return;
|
|
56
|
+
|
|
57
|
+
const unit = /금액|가격|단가|판매가|원가|비용|수수료|배송비|할인|합계|총액|잔액/.test(label)
|
|
58
|
+
? "원"
|
|
59
|
+
: "";
|
|
60
|
+
|
|
61
|
+
const formatProp = prop(node, "format");
|
|
62
|
+
if (!formatProp) {
|
|
63
|
+
context.report({
|
|
64
|
+
node: labelProp,
|
|
65
|
+
messageId: "missingFormat",
|
|
66
|
+
data: { label, unit },
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const src = context.sourceCode.getText(formatProp);
|
|
72
|
+
if (!src.includes("toLocaleString")) {
|
|
73
|
+
context.report({ node: formatProp, messageId: "missingLocale", data: { label } });
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|