@akanjs/devkit 2.4.2-rc.3 → 3.0.0-alpha.1
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/agentsIndex.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +140 -1
- package/qualityScanner.ts +41 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* styleGuard — 색 어휘 폐쇄를 강제하는 순수 정적 검사기 (의존성 0).
|
|
3
|
+
*
|
|
4
|
+
* 어휘 폐쇄(styles.css `@theme { --color-*: initial }`)로 raw 팔레트가 CSS를 미생성하게 되면
|
|
5
|
+
* 위반은 "조용한 시각 깨짐"이 된다. styleGuard 는 소스 문자열을 스캔해 그 위반을 명시적 진단으로
|
|
6
|
+
* 끌어올리고, 에이전트가 읽고 수리할 `suggestion` 을 붙인다.
|
|
7
|
+
*
|
|
8
|
+
* severity 는 규칙 고유값(판별력)만 담는다. warn/error 를 막을지 여부는 배선(호출자)이 mode 로 결정한다:
|
|
9
|
+
* - dev : 절대 막지 않음 (모두 경고 로그)
|
|
10
|
+
* - build/CI/lint : severity==="error" 위반이 하나라도 있으면 실패
|
|
11
|
+
*
|
|
12
|
+
* akanjs 런타임을 import 하지 않는다 — node 표준 라이브러리만 사용.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type StyleGuardRule =
|
|
16
|
+
| "raw-palette"
|
|
17
|
+
| "arbitrary-color"
|
|
18
|
+
| "inline-color"
|
|
19
|
+
| "daisyui-legacy"
|
|
20
|
+
| "interpolated-arbitrary";
|
|
21
|
+
export type StyleGuardSeverity = "error" | "warn";
|
|
22
|
+
|
|
23
|
+
export interface StyleGuardViolation {
|
|
24
|
+
rule: StyleGuardRule;
|
|
25
|
+
severity: StyleGuardSeverity;
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
snippet: string;
|
|
29
|
+
suggestion: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StyleGuardFile {
|
|
33
|
+
path: string;
|
|
34
|
+
content: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NamedComponentClassMetric {
|
|
38
|
+
count: number;
|
|
39
|
+
names: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 색을 받는 유틸 접두사. daisyuiTokenRename.ts 의 PREFIX 와 동일 계보(방향성 border 포함).
|
|
43
|
+
const PREFIX =
|
|
44
|
+
"(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)";
|
|
45
|
+
|
|
46
|
+
// Tailwind 기본 팔레트 이름. `neutral` 은 시맨틱 토큰이기도 하므로 숫자 suffix 를 요구해 구분한다
|
|
47
|
+
// (`bg-neutral` 은 허용, `bg-neutral-500` 은 raw 팔레트).
|
|
48
|
+
const PALETTE =
|
|
49
|
+
"(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)";
|
|
50
|
+
|
|
51
|
+
// 클래스 경계: 앞은 공백/따옴표/백틱/여는 괄호/콜론(변형)/!(important), 뒤는 종결 문자(+ 슬래시=opacity, !).
|
|
52
|
+
const LEAD = "(^|[\\s\"'`{(\\[:!])";
|
|
53
|
+
const TAIL = "(?=[\\s\"'`})\\]:/!,]|$)";
|
|
54
|
+
|
|
55
|
+
const RAW_PALETTE_RE = new RegExp(`${LEAD}((?:${PREFIX})-)${PALETTE}-\\d{2,3}${TAIL}`, "g");
|
|
56
|
+
|
|
57
|
+
// 임의값 대괄호 안의 색 리터럴. `[--var]` 같은 변수 참조는 매치되지 않는다(의도).
|
|
58
|
+
const ARBITRARY_COLOR_RE = /\[(#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb|color)\([^\]]*\))\]/g;
|
|
59
|
+
|
|
60
|
+
// 런타임 값으로 조립한 임의값(`min-h-[${n}px]`, `bg-[${color}]`). Tailwind 는 소스 **텍스트**에서
|
|
61
|
+
// 임의값을 추출하므로 CSS 가 아예 생성되지 않는다 — 클래스는 DOM 에 있고 prop 은 연결된 듯 보이지만
|
|
62
|
+
// 아무것도 적용되지 않는 무증상 실패다. 같은 모양의 리터럴이 코드베이스 어딘가에 있으면 기본값만
|
|
63
|
+
// 우연히 동작하고 override 만 조용히 죽어 더 찾기 어려워진다.
|
|
64
|
+
const INTERPOLATED_ARBITRARY_RE = new RegExp(`${LEAD}[a-z][a-z0-9-]*-\\[[^\\]\`]*\\$\\{`, "g");
|
|
65
|
+
|
|
66
|
+
// style 객체 / <style> 블록. 색 리터럴이 이 안에 있으면 클래스 스캐너를 우회한 것.
|
|
67
|
+
const STYLE_OBJECT_RE = /style=\{\{([\s\S]*?)\}\}/g;
|
|
68
|
+
const STYLE_TAG_RE = /<style[^>]*>([\s\S]*?)<\/style>/g;
|
|
69
|
+
// var(...) 는 허용이므로 매치 대상에서 자연히 빠진다(hex/색함수 리터럴만 탐지).
|
|
70
|
+
const INLINE_COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\(/g;
|
|
71
|
+
|
|
72
|
+
// daisyUI 잔재. 판별력 높은 compound(변형 suffix 포함) 패턴만 error 로 잡아 오탐을 억제한다.
|
|
73
|
+
const DAISYUI_LEGACY_RE = new RegExp(
|
|
74
|
+
`${LEAD}(?:` +
|
|
75
|
+
// 버튼/배지/알림/입력 등 variant compound
|
|
76
|
+
"btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|" +
|
|
77
|
+
"badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|" +
|
|
78
|
+
"alert-(?:info|success|warning|error)|" +
|
|
79
|
+
"input-(?:bordered|primary|secondary|accent|ghost|error)|" +
|
|
80
|
+
"select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|" +
|
|
81
|
+
"checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|" +
|
|
82
|
+
"loading-(?:spinner|dots|ring|ball|bars|infinity)|" +
|
|
83
|
+
// 구조 클래스(daisyUI 전용 조각)
|
|
84
|
+
"card-(?:body|title|actions)|modal-(?:box|action|backdrop)|" +
|
|
85
|
+
"collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|" +
|
|
86
|
+
"stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|" +
|
|
87
|
+
"menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|" +
|
|
88
|
+
"mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle)" +
|
|
89
|
+
`)${TAIL}`,
|
|
90
|
+
"g",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const ALL_RULES: StyleGuardRule[] = [
|
|
94
|
+
"raw-palette",
|
|
95
|
+
"arbitrary-color",
|
|
96
|
+
"inline-color",
|
|
97
|
+
"daisyui-legacy",
|
|
98
|
+
"interpolated-arbitrary",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 위반 억제 지시어(escape hatch). 정당한 팔레트 요청(에디터 신택스 하이라이트, 데이터-viz, 의도적 브랜드
|
|
103
|
+
* 데모)은 거절이 아니라 "명시적 옵트아웃"으로 라우팅한다 — 천장은 제약하지 않되 흔적을 남긴다.
|
|
104
|
+
* styleguard-disable <rule?> → 파일 전체
|
|
105
|
+
* styleguard-disable-next-line <rule?> → 바로 다음 줄
|
|
106
|
+
* rule 을 생략하면 모든 규칙을 억제한다.
|
|
107
|
+
*/
|
|
108
|
+
interface Directives {
|
|
109
|
+
file: Set<StyleGuardRule> | "all" | null;
|
|
110
|
+
nextLine: Map<number, Set<StyleGuardRule> | "all">;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class StyleGuard {
|
|
114
|
+
run(files: StyleGuardFile[]): StyleGuardViolation[] {
|
|
115
|
+
const out: StyleGuardViolation[] = [];
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const directives = this.#parseDirectives(file.content);
|
|
118
|
+
// 주석 내용을 공백으로 치환(offset·줄번호 보존). 주석에 남은 예전 클래스명이 오탐되지 않게.
|
|
119
|
+
// directives 는 원본에서 파싱한다 — 지시어 자체가 주석이기 때문.
|
|
120
|
+
const scan = this.#stripComments(file.content);
|
|
121
|
+
const local: StyleGuardViolation[] = [];
|
|
122
|
+
this.#scanClassLiterals(file, scan, local);
|
|
123
|
+
this.#scanInlineColors(file, scan, local);
|
|
124
|
+
for (const v of local) if (!this.#isSuppressed(v, directives)) out.push(v);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 라인/블록 주석을 같은 길이의 공백으로 치환. 문자열/템플릿 리터럴 안의 `//`·`/*` 는 보존. */
|
|
130
|
+
#stripComments(src: string): string {
|
|
131
|
+
const out = src.split("");
|
|
132
|
+
const blank = (i: number) => {
|
|
133
|
+
if (src[i] !== "\n") out[i] = " ";
|
|
134
|
+
};
|
|
135
|
+
let state: "code" | "line" | "block" | "squote" | "dquote" | "template" = "code";
|
|
136
|
+
let i = 0;
|
|
137
|
+
while (i < src.length) {
|
|
138
|
+
const c = src[i];
|
|
139
|
+
const d = src[i + 1];
|
|
140
|
+
if (state === "code") {
|
|
141
|
+
if (c === "/" && d === "/") {
|
|
142
|
+
blank(i);
|
|
143
|
+
blank(i + 1);
|
|
144
|
+
state = "line";
|
|
145
|
+
i += 2;
|
|
146
|
+
} else if (c === "/" && d === "*") {
|
|
147
|
+
blank(i);
|
|
148
|
+
blank(i + 1);
|
|
149
|
+
state = "block";
|
|
150
|
+
i += 2;
|
|
151
|
+
} else {
|
|
152
|
+
if (c === "'") state = "squote";
|
|
153
|
+
else if (c === '"') state = "dquote";
|
|
154
|
+
else if (c === "`") state = "template";
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
} else if (state === "line") {
|
|
158
|
+
if (c === "\n") state = "code";
|
|
159
|
+
else blank(i);
|
|
160
|
+
i++;
|
|
161
|
+
} else if (state === "block") {
|
|
162
|
+
if (c === "*" && d === "/") {
|
|
163
|
+
blank(i);
|
|
164
|
+
blank(i + 1);
|
|
165
|
+
state = "code";
|
|
166
|
+
i += 2;
|
|
167
|
+
} else {
|
|
168
|
+
blank(i);
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
// 문자열/템플릿: 내용 보존, escape 건너뛰고 종결 문자에서 code 로 복귀.
|
|
173
|
+
const close = state === "squote" ? "'" : state === "dquote" ? '"' : "`";
|
|
174
|
+
if (c === "\\") i += 2;
|
|
175
|
+
else {
|
|
176
|
+
if (c === close) state = "code";
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out.join("");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#parseDirectives(content: string): Directives {
|
|
185
|
+
const directives: Directives = { file: null, nextLine: new Map() };
|
|
186
|
+
const lines = content.split("\n");
|
|
187
|
+
for (let i = 0; i < lines.length; i++) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
const nextLineMatch = line.match(/styleguard-disable-next-line\b(.*)$/);
|
|
190
|
+
if (nextLineMatch) {
|
|
191
|
+
directives.nextLine.set(i + 2, this.#extractRules(nextLineMatch[1]));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const fileMatch = line.match(/styleguard-disable\b(.*)$/);
|
|
195
|
+
if (fileMatch) directives.file = this.#mergeFileScope(directives.file, this.#extractRules(fileMatch[1]));
|
|
196
|
+
}
|
|
197
|
+
return directives;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#extractRules(tail: string): Set<StyleGuardRule> | "all" {
|
|
201
|
+
const found = ALL_RULES.filter((rule) => tail.includes(rule));
|
|
202
|
+
return found.length === 0 ? "all" : new Set(found);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#mergeFileScope(
|
|
206
|
+
prev: Set<StyleGuardRule> | "all" | null,
|
|
207
|
+
next: Set<StyleGuardRule> | "all",
|
|
208
|
+
): Set<StyleGuardRule> | "all" {
|
|
209
|
+
if (prev === "all" || next === "all") return "all";
|
|
210
|
+
return new Set([...(prev ?? []), ...next]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#isSuppressed(v: StyleGuardViolation, directives: Directives): boolean {
|
|
214
|
+
if (directives.file === "all" || directives.file?.has(v.rule)) return true;
|
|
215
|
+
const nextLine = directives.nextLine.get(v.line);
|
|
216
|
+
return nextLine === "all" || (nextLine?.has(v.rule) ?? false);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#scanClassLiterals(file: StyleGuardFile, scan: string, out: StyleGuardViolation[]): void {
|
|
220
|
+
for (const m of scan.matchAll(RAW_PALETTE_RE)) {
|
|
221
|
+
out.push(
|
|
222
|
+
this.#violation(file, m.index ?? 0, {
|
|
223
|
+
rule: "raw-palette",
|
|
224
|
+
severity: "error",
|
|
225
|
+
suggestion:
|
|
226
|
+
"시맨틱 토큰으로 교체하세요 — 예: text-gray-500→text-muted-foreground, bg-red-500→bg-destructive, bg-blue-500→bg-info. 브랜드 색은 page/styles.css 토큰에서 조정합니다.",
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
for (const m of scan.matchAll(ARBITRARY_COLOR_RE)) {
|
|
231
|
+
out.push(
|
|
232
|
+
this.#violation(file, m.index ?? 0, {
|
|
233
|
+
rule: "arbitrary-color",
|
|
234
|
+
severity: "error",
|
|
235
|
+
suggestion:
|
|
236
|
+
"임의 색 리터럴 대신 토큰을 쓰세요 — bg-[#3b82f6]→bg-info, text-[rgb(...)]→시맨틱 토큰. 꼭 필요하면 스코프 토큰(.campaign-x { --primary: … })을 정의하세요.",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
for (const m of scan.matchAll(DAISYUI_LEGACY_RE)) {
|
|
241
|
+
out.push(
|
|
242
|
+
this.#violation(file, m.index ?? 0, {
|
|
243
|
+
rule: "daisyui-legacy",
|
|
244
|
+
severity: "error",
|
|
245
|
+
suggestion:
|
|
246
|
+
"daisyUI 클래스는 제거됐습니다. akanjs/ui 프리미티브(Button/Badge 등)나 buttonRecipe()/badgeRecipe() + 시맨틱 토큰으로 교체하세요.",
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
for (const m of scan.matchAll(INTERPOLATED_ARBITRARY_RE)) {
|
|
251
|
+
out.push(
|
|
252
|
+
this.#violation(file, m.index ?? 0, {
|
|
253
|
+
rule: "interpolated-arbitrary",
|
|
254
|
+
severity: "error",
|
|
255
|
+
suggestion:
|
|
256
|
+
"런타임 값으로 임의값 클래스를 조립하면 CSS 가 생성되지 않습니다(스캐너는 소스 텍스트를 읽습니다). 크기/위치는 style prop 으로 넘기세요 — style={{ minHeight }}. 값이 enum 이면 리터럴 클래스 맵으로 두세요.",
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#scanInlineColors(file: StyleGuardFile, scan: string, out: StyleGuardViolation[]): void {
|
|
263
|
+
const push = (index: number) =>
|
|
264
|
+
out.push(
|
|
265
|
+
this.#violation(file, index, {
|
|
266
|
+
rule: "inline-color",
|
|
267
|
+
severity: "error",
|
|
268
|
+
suggestion:
|
|
269
|
+
"인라인 색 리터럴 대신 CSS 변수/토큰을 참조하세요 — style={{ color: 'var(--primary)' }} 또는 시맨틱 클래스(text-primary). 하드코딩 hex/rgb 는 테마 전환을 깨뜨립니다.",
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
for (const region of [STYLE_OBJECT_RE, STYLE_TAG_RE]) {
|
|
273
|
+
for (const m of scan.matchAll(region)) {
|
|
274
|
+
const inner = m[1] ?? "";
|
|
275
|
+
const innerStart = (m.index ?? 0) + m[0].indexOf(inner);
|
|
276
|
+
for (const lit of inner.matchAll(INLINE_COLOR_LITERAL_RE)) {
|
|
277
|
+
push(innerStart + (lit.index ?? 0));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#violation(
|
|
284
|
+
file: StyleGuardFile,
|
|
285
|
+
index: number,
|
|
286
|
+
base: Pick<StyleGuardViolation, "rule" | "severity" | "suggestion">,
|
|
287
|
+
): StyleGuardViolation {
|
|
288
|
+
const before = file.content.slice(0, index);
|
|
289
|
+
const line = before.length === 0 ? 1 : before.split("\n").length;
|
|
290
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
291
|
+
const lineEnd = file.content.indexOf("\n", index);
|
|
292
|
+
const snippet = file.content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
293
|
+
return { ...base, path: file.path, line, snippet };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `@layer components` 안에서 선언된 명명 클래스 개수 — 그림자 디자인 시스템 증식의 warn 지표(차단 X).
|
|
298
|
+
*/
|
|
299
|
+
static countNamedComponentClasses(css: string): NamedComponentClassMetric {
|
|
300
|
+
const names = new Set<string>();
|
|
301
|
+
const layerRe = /@layer\s+components\s*\{/g;
|
|
302
|
+
for (const layer of css.matchAll(layerRe)) {
|
|
303
|
+
const bodyStart = (layer.index ?? 0) + layer[0].length;
|
|
304
|
+
const body = StyleGuard.#extractBalanced(css, bodyStart);
|
|
305
|
+
for (const sel of body.matchAll(/(^|[\s}])\.(-?[a-zA-Z_][\w-]*)/g)) names.add(sel[2]);
|
|
306
|
+
}
|
|
307
|
+
return { count: names.size, names: [...names].sort() };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
static #extractBalanced(css: string, start: number): string {
|
|
311
|
+
let depth = 1;
|
|
312
|
+
for (let i = start; i < css.length; i++) {
|
|
313
|
+
const ch = css[i];
|
|
314
|
+
if (ch === "{") depth++;
|
|
315
|
+
else if (ch === "}") {
|
|
316
|
+
depth--;
|
|
317
|
+
if (depth === 0) return css.slice(start, i);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return css.slice(start);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ThemeValidator } from "./themeValidator";
|
|
5
|
+
|
|
6
|
+
const validator = new ThemeValidator();
|
|
7
|
+
|
|
8
|
+
describe("ThemeValidator.parseHex", () => {
|
|
9
|
+
test("parses shorthand, full, and alpha hex", () => {
|
|
10
|
+
expect(ThemeValidator.parseHex("#fff")).toEqual([255, 255, 255]);
|
|
11
|
+
expect(ThemeValidator.parseHex("#0a0a0a")).toEqual([10, 10, 10]);
|
|
12
|
+
expect(ThemeValidator.parseHex("#ffffff80")).toEqual([255, 255, 255]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns null for non-hex values", () => {
|
|
16
|
+
expect(ThemeValidator.parseHex("var(--primary)")).toBeNull();
|
|
17
|
+
expect(ThemeValidator.parseHex("rgb(0,0,0)")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("ThemeValidator.contrastRatio", () => {
|
|
22
|
+
test("black/white is 21:1", () => {
|
|
23
|
+
expect(ThemeValidator.contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("ThemeValidator.parseThemeTokens", () => {
|
|
28
|
+
test("distributes grouped selectors and normalizes quotes", () => {
|
|
29
|
+
const css = `:root, [data-theme=dark] { --background: #0a0a0a; --foreground: #fafafa; }`;
|
|
30
|
+
const tokens = ThemeValidator.parseThemeTokens(css);
|
|
31
|
+
expect(tokens[":root"].background).toBe("#0a0a0a");
|
|
32
|
+
expect(tokens['[data-theme="dark"]'].foreground).toBe("#fafafa");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ignores at-rule blocks like @theme", () => {
|
|
36
|
+
const css = `@theme inline { --color-primary: var(--primary); } :root { --background: #fff; }`;
|
|
37
|
+
const tokens = ThemeValidator.parseThemeTokens(css);
|
|
38
|
+
expect(Object.keys(tokens)).toEqual([":root"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("ThemeValidator.validate", () => {
|
|
43
|
+
test("flags a low-contrast pair", () => {
|
|
44
|
+
const css = `:root { --background: #ffffff; --foreground: #eeeeee; }`;
|
|
45
|
+
const violations = validator.validate(css);
|
|
46
|
+
expect(violations).toHaveLength(1);
|
|
47
|
+
expect(violations[0].pair).toBe("background / foreground");
|
|
48
|
+
expect(violations[0].ratio).toBeLessThan(4.5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("passes when status pairs meet the relaxed 3:1 threshold", () => {
|
|
52
|
+
// info #3b82f6 on white ≈ 3.68 — below 4.5 but above the 3:1 UI threshold.
|
|
53
|
+
const css = `:root { --info: #3b82f6; --info-foreground: #ffffff; }`;
|
|
54
|
+
expect(validator.validate(css)).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("skips pairs whose token is a var() reference", () => {
|
|
58
|
+
const css = `:root { --primary: var(--brand); --primary-foreground: #000000; }`;
|
|
59
|
+
expect(validator.validate(css)).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("shipped framework palette", () => {
|
|
64
|
+
test("pkgs/akanjs/ui/styles.css passes WCAG contrast for all scopes", () => {
|
|
65
|
+
const stylesPath = path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css");
|
|
66
|
+
const css = readFileSync(stylesPath, "utf8");
|
|
67
|
+
const violations = validator.validate(css);
|
|
68
|
+
expect(violations).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* themeValidator — 시맨틱 토큰 페어의 WCAG 콘트라스트 자동 검사 (의존성 0).
|
|
3
|
+
*
|
|
4
|
+
* 토큰 구조(`--x` ↔ `--x-foreground`)라서 가능해진 검사. AI 가 생성한 팔레트가 "안 읽히는 사이트"가
|
|
5
|
+
* 되는 것을 원천 차단한다. akanjs 런타임을 import 하지 않는다 — 순수 함수만.
|
|
6
|
+
*
|
|
7
|
+
* 임계값(WCAG 2.1):
|
|
8
|
+
* - 본문/주요 표면 페어(background·primary·secondary·accent·neutral·card·popover): 4.5:1 (AA normal text)
|
|
9
|
+
* - 상태/보조 페어(info·success·warning·destructive·open·muted): 3:1 (UI 컴포넌트 / 큰 텍스트 / 보조 표면)
|
|
10
|
+
* 현행 styles.css 기본 팔레트(light/dark)는 이 임계값을 모두 통과한다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ThemeContrastViolation {
|
|
14
|
+
scope: string;
|
|
15
|
+
pair: string;
|
|
16
|
+
background: string;
|
|
17
|
+
foreground: string;
|
|
18
|
+
ratio: number;
|
|
19
|
+
threshold: number;
|
|
20
|
+
suggestion: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PairDef {
|
|
24
|
+
base: string;
|
|
25
|
+
fg: string;
|
|
26
|
+
threshold: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PAIRS: PairDef[] = [
|
|
30
|
+
{ base: "background", fg: "foreground", threshold: 4.5 },
|
|
31
|
+
{ base: "primary", fg: "primary-foreground", threshold: 4.5 },
|
|
32
|
+
{ base: "secondary", fg: "secondary-foreground", threshold: 4.5 },
|
|
33
|
+
{ base: "accent", fg: "accent-foreground", threshold: 4.5 },
|
|
34
|
+
{ base: "neutral", fg: "neutral-foreground", threshold: 4.5 },
|
|
35
|
+
{ base: "card", fg: "card-foreground", threshold: 4.5 },
|
|
36
|
+
{ base: "popover", fg: "popover-foreground", threshold: 4.5 },
|
|
37
|
+
{ base: "muted", fg: "muted-foreground", threshold: 3 },
|
|
38
|
+
{ base: "info", fg: "info-foreground", threshold: 3 },
|
|
39
|
+
{ base: "success", fg: "success-foreground", threshold: 3 },
|
|
40
|
+
{ base: "warning", fg: "warning-foreground", threshold: 3 },
|
|
41
|
+
{ base: "destructive", fg: "destructive-foreground", threshold: 3 },
|
|
42
|
+
{ base: "open", fg: "open-foreground", threshold: 3 },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// 검사 대상 스코프. 그 외 셀렉터(.campaign-x 스코프 토큰 등)는 페어 검사에서 제외.
|
|
46
|
+
const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
|
|
47
|
+
|
|
48
|
+
export type ThemeTokensByScope = Record<string, Record<string, string>>;
|
|
49
|
+
|
|
50
|
+
export class ThemeValidator {
|
|
51
|
+
/** CSS 문자열에서 토큰을 추출해 알려진 테마 스코프 전체를 검사한다. */
|
|
52
|
+
validate(css: string): ThemeContrastViolation[] {
|
|
53
|
+
const tokensByScope = ThemeValidator.parseThemeTokens(css);
|
|
54
|
+
const violations: ThemeContrastViolation[] = [];
|
|
55
|
+
for (const [scope, tokens] of Object.entries(tokensByScope)) {
|
|
56
|
+
if (!THEME_SCOPES.has(scope)) continue;
|
|
57
|
+
violations.push(...this.validateScope(tokens, scope));
|
|
58
|
+
}
|
|
59
|
+
return violations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validateScope(tokens: Record<string, string>, scope: string): ThemeContrastViolation[] {
|
|
63
|
+
const violations: ThemeContrastViolation[] = [];
|
|
64
|
+
for (const { base, fg, threshold } of PAIRS) {
|
|
65
|
+
const bg = tokens[base];
|
|
66
|
+
const front = tokens[fg];
|
|
67
|
+
if (!bg || !front) continue;
|
|
68
|
+
const bgRgb = ThemeValidator.parseHex(bg);
|
|
69
|
+
const fgRgb = ThemeValidator.parseHex(front);
|
|
70
|
+
if (!bgRgb || !fgRgb) continue; // var()/비-hex 값은 검사 불가 → 건너뜀
|
|
71
|
+
const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
|
|
72
|
+
if (ratio >= threshold) continue;
|
|
73
|
+
violations.push({
|
|
74
|
+
scope,
|
|
75
|
+
pair: `${base} / ${fg}`,
|
|
76
|
+
background: bg,
|
|
77
|
+
foreground: front,
|
|
78
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
79
|
+
threshold,
|
|
80
|
+
suggestion: `${scope}의 --${base}(${bg})와 --${fg}(${front}) 대비가 ${ratio.toFixed(2)}:1 로 최소 ${threshold}:1 미만입니다. 한쪽을 더 밝게/어둡게 조정해 대비를 확보하세요.`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return violations;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `:root` / `[data-theme="…"]` 블록에서 `--token: value` 를 파싱한다. 그룹 셀렉터
|
|
88
|
+
* (`:root, [data-theme="dark"] { … }`)는 각 셀렉터에 동일 토큰을 분배. 동일 스코프 재등장 시 나중 값이 이긴다
|
|
89
|
+
* (프레임워크-먼저 / 앱-나중 순서로 넘기면 앱 override 가 반영됨).
|
|
90
|
+
*/
|
|
91
|
+
static parseThemeTokens(css: string): ThemeTokensByScope {
|
|
92
|
+
const result: ThemeTokensByScope = {};
|
|
93
|
+
// 중첩 없는 단순 규칙 블록만 매칭(@theme/@keyframes 등 at-rule 은 셀렉터에 @ 포함이라 제외).
|
|
94
|
+
const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
|
|
95
|
+
for (const block of css.matchAll(blockRe)) {
|
|
96
|
+
const selectors = block[1].split(",").map((s) => s.trim());
|
|
97
|
+
const relevant = selectors.filter((s) => THEME_SCOPES.has(ThemeValidator.#normalizeScope(s)));
|
|
98
|
+
if (relevant.length === 0) continue;
|
|
99
|
+
const decls: Record<string, string> = {};
|
|
100
|
+
for (const decl of block[2].matchAll(/--([\w-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
|
|
101
|
+
decls[decl[1]] = decl[2].trim();
|
|
102
|
+
}
|
|
103
|
+
for (const selector of relevant) {
|
|
104
|
+
const scope = ThemeValidator.#normalizeScope(selector);
|
|
105
|
+
result[scope] = { ...(result[scope] ?? {}), ...decls };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #normalizeScope(selector: string): string {
|
|
112
|
+
// 따옴표 정규화: [data-theme=dark] / [data-theme='dark'] → [data-theme="dark"]
|
|
113
|
+
return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa → [r,g,b] (alpha 무시). 비-hex 는 null. */
|
|
117
|
+
static parseHex(value: string): [number, number, number] | null {
|
|
118
|
+
const v = value.trim();
|
|
119
|
+
if (!v.startsWith("#")) return null;
|
|
120
|
+
const hex = v.slice(1);
|
|
121
|
+
let full: string;
|
|
122
|
+
if (hex.length === 3 || hex.length === 4)
|
|
123
|
+
full = hex
|
|
124
|
+
.slice(0, 3)
|
|
125
|
+
.split("")
|
|
126
|
+
.map((c) => c + c)
|
|
127
|
+
.join("");
|
|
128
|
+
else if (hex.length === 6 || hex.length === 8) full = hex.slice(0, 6);
|
|
129
|
+
else return null;
|
|
130
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
|
131
|
+
const n = Number.parseInt(full, 16);
|
|
132
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static #relativeLuminance([r, g, b]: [number, number, number]): number {
|
|
136
|
+
const channel = (c: number) => {
|
|
137
|
+
const s = c / 255;
|
|
138
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
139
|
+
};
|
|
140
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static contrastRatio(a: [number, number, number], b: [number, number, number]): number {
|
|
144
|
+
const la = ThemeValidator.#relativeLuminance(a);
|
|
145
|
+
const lb = ThemeValidator.#relativeLuminance(b);
|
|
146
|
+
const hi = Math.max(la, lb);
|
|
147
|
+
const lo = Math.min(la, lb);
|
|
148
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type * from "./mobile";
|
|
|
42
42
|
export type * from "./packageExportsMap";
|
|
43
43
|
export type * from "./prompter";
|
|
44
44
|
export type * from "./qualityScanner";
|
|
45
|
+
export type * from "./recipeScanner";
|
|
45
46
|
export type * from "./scanInfo";
|
|
46
47
|
export type * from "./selectModel";
|
|
47
48
|
export type * from "./spinner";
|
|
@@ -285,9 +285,14 @@ export class DevResourceProbe {
|
|
|
285
285
|
"rscWorkerRecycleCount",
|
|
286
286
|
"httpFullSsrCount",
|
|
287
287
|
];
|
|
288
|
-
|
|
288
|
+
// `rssBytes` is the replica's own; the RSC worker is a separate process reporting under
|
|
289
|
+
// `rscWorker*`. These used to be the same field, because the worker's report shadowed the
|
|
290
|
+
// replica's — so this line printed the worker's RSS labelled as the child's.
|
|
291
|
+
const toMb = (bytes: unknown) => (Number(bytes ?? 0) / 1024 / 1024).toFixed(0);
|
|
289
292
|
const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
|
|
290
|
-
console.info(
|
|
293
|
+
console.info(
|
|
294
|
+
`[metrics ${label}] replicaRss=${toMb(child.rssBytes)}MB rscWorkerRss=${toMb(child.rscWorkerRssBytes)}MB ${parts.join(" ")}`,
|
|
295
|
+
);
|
|
291
296
|
}
|
|
292
297
|
|
|
293
298
|
async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {
|