@ncds/ui-admin-mcp 1.0.0-alpha.2
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/README.md +113 -0
- package/bin/components.bundle.js +21 -0
- package/bin/definitions/compliance-rules.json +64 -0
- package/bin/definitions/instructions.md +96 -0
- package/bin/definitions/rules.json +57 -0
- package/bin/definitions/token-descriptions.json +27 -0
- package/bin/definitions/tool-definitions.json +42 -0
- package/bin/instructions.d.ts +1 -0
- package/bin/instructions.js +14 -0
- package/bin/server.d.ts +1 -0
- package/bin/server.js +164 -0
- package/bin/server.mjs +8 -0
- package/bin/tools/getComponentHtml.d.ts +3 -0
- package/bin/tools/getComponentHtml.js +30 -0
- package/bin/tools/getComponentProps.d.ts +4 -0
- package/bin/tools/getComponentProps.js +17 -0
- package/bin/tools/getDesignTokens.d.ts +13 -0
- package/bin/tools/getDesignTokens.js +20 -0
- package/bin/tools/listComponents.d.ts +16 -0
- package/bin/tools/listComponents.js +24 -0
- package/bin/tools/listIcons.d.ts +22 -0
- package/bin/tools/listIcons.js +23 -0
- package/bin/tools/ping.d.ts +17 -0
- package/bin/tools/ping.js +20 -0
- package/bin/tools/renderToHtml.d.ts +21 -0
- package/bin/tools/renderToHtml.js +177 -0
- package/bin/tools/searchComponent.d.ts +4 -0
- package/bin/tools/searchComponent.js +33 -0
- package/bin/tools/searchIcon.d.ts +7 -0
- package/bin/tools/searchIcon.js +19 -0
- package/bin/tools/validateHtml.d.ts +18 -0
- package/bin/tools/validateHtml.js +85 -0
- package/bin/types.d.ts +111 -0
- package/bin/types.js +5 -0
- package/bin/utils/bemValidator.d.ts +36 -0
- package/bin/utils/bemValidator.js +198 -0
- package/bin/utils/compliance.d.ts +52 -0
- package/bin/utils/compliance.js +199 -0
- package/bin/utils/dataLoader.d.ts +33 -0
- package/bin/utils/dataLoader.js +174 -0
- package/bin/utils/domEnvironment.d.ts +9 -0
- package/bin/utils/domEnvironment.js +25 -0
- package/bin/utils/fuzzyMatch.d.ts +21 -0
- package/bin/utils/fuzzyMatch.js +110 -0
- package/bin/utils/logger.d.ts +18 -0
- package/bin/utils/logger.js +27 -0
- package/bin/utils/response.d.ts +26 -0
- package/bin/utils/response.js +28 -0
- package/bin/utils/tokenValidator.d.ts +24 -0
- package/bin/utils/tokenValidator.js +162 -0
- package/bin/version.d.ts +4 -0
- package/bin/version.js +7 -0
- package/data/_icons.json +12361 -0
- package/data/_meta.json +12 -0
- package/data/_tokens.json +661 -0
- package/data/badge-group.json +121 -0
- package/data/badge.json +130 -0
- package/data/bread-crumb.json +51 -0
- package/data/button-group.json +94 -0
- package/data/button.json +143 -0
- package/data/carousel-arrow.json +87 -0
- package/data/carousel-number-group.json +87 -0
- package/data/checkbox.json +99 -0
- package/data/combo-box.json +157 -0
- package/data/date-picker.json +109 -0
- package/data/divider.json +91 -0
- package/data/dot.json +103 -0
- package/data/dropdown.json +123 -0
- package/data/empty-state.json +64 -0
- package/data/featured-icon.json +125 -0
- package/data/file-input.json +161 -0
- package/data/horizontal-tab.json +114 -0
- package/data/image-file-input.json +185 -0
- package/data/input-base.json +145 -0
- package/data/modal.json +131 -0
- package/data/notification.json +176 -0
- package/data/number-input.json +141 -0
- package/data/pagination.json +101 -0
- package/data/password-input.json +45 -0
- package/data/progress-bar.json +90 -0
- package/data/progress-circle.json +96 -0
- package/data/radio.json +86 -0
- package/data/range-date-picker-with-buttons.json +101 -0
- package/data/range-date-picker.json +87 -0
- package/data/select-box.json +177 -0
- package/data/select.json +116 -0
- package/data/slider.json +100 -0
- package/data/spinner.json +94 -0
- package/data/switch.json +109 -0
- package/data/tag.json +101 -0
- package/data/textarea.json +96 -0
- package/data/toggle.json +102 -0
- package/data/tooltip.json +185 -0
- package/data/vertical-tab.json +99 -0
- package/package.json +71 -0
- package/templates/.mcp.json.example +8 -0
- package/templates/README.md +30 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setupDomEnvironment = void 0;
|
|
4
|
+
/** jsdom + React SSR 환경을 초기화하고 ReactRuntime을 반환 */
|
|
5
|
+
const setupDomEnvironment = () => {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
7
|
+
const { JSDOM } = require('jsdom');
|
|
8
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
|
9
|
+
if (typeof globalThis.document === 'undefined')
|
|
10
|
+
globalThis.document = dom.window.document;
|
|
11
|
+
if (typeof globalThis.window === 'undefined')
|
|
12
|
+
globalThis.window = dom.window;
|
|
13
|
+
if (typeof globalThis.navigator === 'undefined') {
|
|
14
|
+
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, writable: true });
|
|
15
|
+
}
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
17
|
+
const ReactDOM = require('react-dom');
|
|
18
|
+
ReactDOM.createPortal = (children) => children;
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
20
|
+
const React = require('react');
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
22
|
+
const { renderToStaticMarkup } = require('react-dom/server');
|
|
23
|
+
return { createElement: React.createElement, renderToStaticMarkup };
|
|
24
|
+
};
|
|
25
|
+
exports.setupDomEnvironment = setupDomEnvironment;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 한국어/영어 퍼지 매칭 유틸
|
|
3
|
+
*
|
|
4
|
+
* - 공백 제거 후 비교 ("약관동의" ↔ "약관 동의")
|
|
5
|
+
* - 부분 포함 ("비번" ⊂ "비밀번호"는 안 되지만, "로딩" ⊂ "로딩중"은 됨)
|
|
6
|
+
* - 편집 거리(Levenshtein) 기반 오타 허용 ("셀랙트" ↔ "셀렉트", "buttn" ↔ "button")
|
|
7
|
+
*/
|
|
8
|
+
export interface FuzzyResult {
|
|
9
|
+
score: number;
|
|
10
|
+
matchType: 'exact' | 'contains' | 'fuzzy';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* query가 target에 매칭되는지 퍼지 검사
|
|
14
|
+
*
|
|
15
|
+
* @returns null이면 매칭 실패, FuzzyResult면 매칭 성공
|
|
16
|
+
*/
|
|
17
|
+
export declare const fuzzyMatch: (query: string, target: string) => FuzzyResult | null;
|
|
18
|
+
/**
|
|
19
|
+
* query를 여러 targets에 대해 퍼지 매칭하고 최고 점수를 반환
|
|
20
|
+
*/
|
|
21
|
+
export declare const bestFuzzyMatch: (query: string, targets: string[]) => FuzzyResult | null;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 한국어/영어 퍼지 매칭 유틸
|
|
4
|
+
*
|
|
5
|
+
* - 공백 제거 후 비교 ("약관동의" ↔ "약관 동의")
|
|
6
|
+
* - 부분 포함 ("비번" ⊂ "비밀번호"는 안 되지만, "로딩" ⊂ "로딩중"은 됨)
|
|
7
|
+
* - 편집 거리(Levenshtein) 기반 오타 허용 ("셀랙트" ↔ "셀렉트", "buttn" ↔ "button")
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.bestFuzzyMatch = exports.fuzzyMatch = void 0;
|
|
11
|
+
// ── 상수 ──────────────────────────────────────────────────────────
|
|
12
|
+
/** 포함 매칭 시 기본 점수 */
|
|
13
|
+
const CONTAINS_BASE_SCORE = 0.7;
|
|
14
|
+
/** 포함 매칭 시 길이 비율 보너스 가중치 */
|
|
15
|
+
const CONTAINS_BONUS_MULTIPLIER = 0.2;
|
|
16
|
+
/** 이 길이 이하의 쿼리는 "짧은 쿼리"로 분류 (typo threshold = 1) */
|
|
17
|
+
const SHORT_QUERY_LENGTH = 2;
|
|
18
|
+
/** 긴 쿼리의 오타 허용 비율 (ceil(length * 이 값)) */
|
|
19
|
+
const TYPO_THRESHOLD_RATIO = 0.3;
|
|
20
|
+
/** target이 query보다 이만큼 이상 길면 부분 문자열 매칭 시도 */
|
|
21
|
+
const LENGTH_DIFF_FOR_SUBSTRING = 2;
|
|
22
|
+
/** 부분 문자열 매칭을 시도할 target 최대 길이 */
|
|
23
|
+
const SUBSTRING_TARGET_LIMIT = 100;
|
|
24
|
+
/** 부분 문자열 퍼지 매칭 시 기본 점수 */
|
|
25
|
+
const SUBSTRING_BASE_SCORE = 0.5;
|
|
26
|
+
/** 부분 문자열 퍼지 매칭 시 유사도 보너스 가중치 */
|
|
27
|
+
const SUBSTRING_BONUS_MULTIPLIER = 0.2;
|
|
28
|
+
// ── 순수 함수 ────────────────────────────────────────────────────
|
|
29
|
+
/** 공백·하이픈 제거 후 소문자 */
|
|
30
|
+
const normalize = (s) => s.toLowerCase().replace(/[\s\-_]/g, '');
|
|
31
|
+
/** Levenshtein 편집 거리 */
|
|
32
|
+
const editDistance = (a, b) => {
|
|
33
|
+
const m = a.length;
|
|
34
|
+
const n = b.length;
|
|
35
|
+
if (m === 0)
|
|
36
|
+
return n;
|
|
37
|
+
if (n === 0)
|
|
38
|
+
return m;
|
|
39
|
+
// 짧은 배열만 유지 (메모리 최적화)
|
|
40
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
41
|
+
let curr = new Array(n + 1);
|
|
42
|
+
for (let i = 1; i <= m; i++) {
|
|
43
|
+
curr[0] = i;
|
|
44
|
+
for (let j = 1; j <= n; j++) {
|
|
45
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
46
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
47
|
+
}
|
|
48
|
+
[prev, curr] = [curr, prev];
|
|
49
|
+
}
|
|
50
|
+
return prev[n];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* query가 target에 매칭되는지 퍼지 검사
|
|
54
|
+
*
|
|
55
|
+
* @returns null이면 매칭 실패, FuzzyResult면 매칭 성공
|
|
56
|
+
*/
|
|
57
|
+
const fuzzyMatch = (query, target) => {
|
|
58
|
+
const q = normalize(query);
|
|
59
|
+
const t = normalize(target);
|
|
60
|
+
if (!q || !t)
|
|
61
|
+
return null;
|
|
62
|
+
// 1. 정확 일치 (공백 제거 후)
|
|
63
|
+
if (t === q)
|
|
64
|
+
return { score: 1.0, matchType: 'exact' };
|
|
65
|
+
// 2. 포함 매칭 (공백 제거 후)
|
|
66
|
+
if (t.includes(q) || q.includes(t)) {
|
|
67
|
+
const shorter = Math.min(q.length, t.length);
|
|
68
|
+
const longer = Math.max(q.length, t.length);
|
|
69
|
+
return { score: CONTAINS_BASE_SCORE + CONTAINS_BONUS_MULTIPLIER * (shorter / longer), matchType: 'contains' };
|
|
70
|
+
}
|
|
71
|
+
// 3. 편집 거리 기반 퍼지 매칭
|
|
72
|
+
const isShortQuery = q.length <= SHORT_QUERY_LENGTH;
|
|
73
|
+
const typoThreshold = isShortQuery ? 1 : Math.ceil(q.length * TYPO_THRESHOLD_RATIO);
|
|
74
|
+
const dist = editDistance(q, t);
|
|
75
|
+
// target이 query보다 훨씬 길면, 부분 문자열 슬라이딩 윈도우로 비교
|
|
76
|
+
const targetIsLonger = t.length > q.length + LENGTH_DIFF_FOR_SUBSTRING;
|
|
77
|
+
const queryIsLongEnough = q.length >= SHORT_QUERY_LENGTH;
|
|
78
|
+
const targetWithinLimit = t.length <= SUBSTRING_TARGET_LIMIT;
|
|
79
|
+
const shouldTrySubstringMatch = targetIsLonger && queryIsLongEnough && targetWithinLimit;
|
|
80
|
+
if (shouldTrySubstringMatch) {
|
|
81
|
+
for (let i = 0; i <= t.length - q.length; i++) {
|
|
82
|
+
const sub = t.substring(i, i + q.length);
|
|
83
|
+
const subDist = editDistance(q, sub);
|
|
84
|
+
if (subDist <= typoThreshold) {
|
|
85
|
+
const subSimilarity = q.length === 0 ? 1 : 1 - subDist / q.length;
|
|
86
|
+
return { score: SUBSTRING_BASE_SCORE + SUBSTRING_BONUS_MULTIPLIER * subSimilarity, matchType: 'fuzzy' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (dist <= typoThreshold) {
|
|
91
|
+
const maxLen = Math.max(q.length, t.length);
|
|
92
|
+
return { score: maxLen === 0 ? 1 : 1 - dist / maxLen, matchType: 'fuzzy' };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
exports.fuzzyMatch = fuzzyMatch;
|
|
97
|
+
/**
|
|
98
|
+
* query를 여러 targets에 대해 퍼지 매칭하고 최고 점수를 반환
|
|
99
|
+
*/
|
|
100
|
+
const bestFuzzyMatch = (query, targets) => {
|
|
101
|
+
let best = null;
|
|
102
|
+
for (const target of targets) {
|
|
103
|
+
const result = (0, exports.fuzzyMatch)(query, target);
|
|
104
|
+
if (result && (!best || result.score > best.score)) {
|
|
105
|
+
best = result;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return best;
|
|
109
|
+
};
|
|
110
|
+
exports.bestFuzzyMatch = bestFuzzyMatch;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderr 전용 logger 유틸리티 — 부수효과 함수 (stderr I/O)
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ console.log / console.warn / console.error 직접 사용 금지
|
|
5
|
+
*
|
|
6
|
+
* MCP stdio 프로토콜은 채널 역할이 엄격히 분리됩니다.
|
|
7
|
+
* stdin → 클라이언트 → 서버 요청(JSON-RPC)
|
|
8
|
+
* stdout → 서버 → 클라이언트 응답(JSON-RPC) ← 프로토콜 전용 채널
|
|
9
|
+
* stderr → 로그 전용 (프로토콜 밖)
|
|
10
|
+
*
|
|
11
|
+
* console.log는 stdout에 출력되어 JSON-RPC 스트림을 오염시킵니다.
|
|
12
|
+
* 모든 로깅은 반드시 이 logger를 통해 stderr로 출력해야 합니다.
|
|
13
|
+
*/
|
|
14
|
+
export declare const logger: {
|
|
15
|
+
info: (msg: string) => void;
|
|
16
|
+
warn: (msg: string) => void;
|
|
17
|
+
error: (msg: string) => void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.logger = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* stderr 전용 logger 유틸리티 — 부수효과 함수 (stderr I/O)
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ console.log / console.warn / console.error 직접 사용 금지
|
|
8
|
+
*
|
|
9
|
+
* MCP stdio 프로토콜은 채널 역할이 엄격히 분리됩니다.
|
|
10
|
+
* stdin → 클라이언트 → 서버 요청(JSON-RPC)
|
|
11
|
+
* stdout → 서버 → 클라이언트 응답(JSON-RPC) ← 프로토콜 전용 채널
|
|
12
|
+
* stderr → 로그 전용 (프로토콜 밖)
|
|
13
|
+
*
|
|
14
|
+
* console.log는 stdout에 출력되어 JSON-RPC 스트림을 오염시킵니다.
|
|
15
|
+
* 모든 로깅은 반드시 이 logger를 통해 stderr로 출력해야 합니다.
|
|
16
|
+
*/
|
|
17
|
+
exports.logger = {
|
|
18
|
+
info: (msg) => {
|
|
19
|
+
process.stderr.write(`[INFO] ${msg}\n`);
|
|
20
|
+
},
|
|
21
|
+
warn: (msg) => {
|
|
22
|
+
process.stderr.write(`[WARN] ${msg}\n`);
|
|
23
|
+
},
|
|
24
|
+
error: (msg) => {
|
|
25
|
+
process.stderr.write(`[ERROR] ${msg}\n`);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool 응답 헬퍼 — 순수 함수
|
|
3
|
+
*
|
|
4
|
+
* 모든 tool이 동일한 응답 구조를 사용하도록 통일합니다.
|
|
5
|
+
* AI가 tool 코드를 읽을 때 응답 구조를 이 파일 하나로 파악할 수 있습니다.
|
|
6
|
+
*/
|
|
7
|
+
import type { McpErrorCode } from '../types.js';
|
|
8
|
+
/** MCP tool 응답 타입 — registerTool 콜백 반환 타입과 호환 */
|
|
9
|
+
export type McpToolResponse = {
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
content: Array<{
|
|
12
|
+
type: 'text';
|
|
13
|
+
text: string;
|
|
14
|
+
}>;
|
|
15
|
+
isError?: true;
|
|
16
|
+
};
|
|
17
|
+
/** 성공 응답 — data를 JSON.stringify하여 반환 */
|
|
18
|
+
export declare const successResponse: (data: unknown) => McpToolResponse;
|
|
19
|
+
/** 오류 응답 — code + message + suggestion 구조로 isError: true 반환 */
|
|
20
|
+
export declare const errorResponse: (code: McpErrorCode, message: string, suggestion: string) => McpToolResponse;
|
|
21
|
+
/** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
|
|
22
|
+
export declare const componentNotFoundResponse: (name: string) => McpToolResponse;
|
|
23
|
+
/** 입력 정규화 — name.trim().toLowerCase() */
|
|
24
|
+
export declare const normalizeName: (name: string) => string;
|
|
25
|
+
/** catch 블록에서 에러 메시지를 안전하게 추출 */
|
|
26
|
+
export declare const toErrorMessage: (err: unknown) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toErrorMessage = exports.normalizeName = exports.componentNotFoundResponse = exports.errorResponse = exports.successResponse = void 0;
|
|
4
|
+
/** 성공 응답 — data를 JSON.stringify하여 반환 */
|
|
5
|
+
const successResponse = (data) => ({
|
|
6
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
7
|
+
});
|
|
8
|
+
exports.successResponse = successResponse;
|
|
9
|
+
/** 오류 응답 — code + message + suggestion 구조로 isError: true 반환 */
|
|
10
|
+
const errorResponse = (code, message, suggestion) => ({
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
type: 'text',
|
|
14
|
+
text: JSON.stringify({ code, message, suggestion }),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
isError: true,
|
|
18
|
+
});
|
|
19
|
+
exports.errorResponse = errorResponse;
|
|
20
|
+
/** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
|
|
21
|
+
const componentNotFoundResponse = (name) => (0, exports.errorResponse)('COMPONENT_NOT_FOUND', `Component '${name}' not found.`, 'Use search_component to find similar components, or list_components to see all available.');
|
|
22
|
+
exports.componentNotFoundResponse = componentNotFoundResponse;
|
|
23
|
+
/** 입력 정규화 — name.trim().toLowerCase() */
|
|
24
|
+
const normalizeName = (name) => name.trim().toLowerCase();
|
|
25
|
+
exports.normalizeName = normalizeName;
|
|
26
|
+
/** catch 블록에서 에러 메시지를 안전하게 추출 */
|
|
27
|
+
const toErrorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
28
|
+
exports.toErrorMessage = toErrorMessage;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 토큰 검증 순수 함수 — 하드코딩 색상 감지 + 존재하지 않는 토큰 감지
|
|
3
|
+
*
|
|
4
|
+
* compliance.ts에서 분리. 모든 함수는 순수 함수(Calculation).
|
|
5
|
+
*/
|
|
6
|
+
import type { parse } from 'node-html-parser';
|
|
7
|
+
import type { ComplianceError, TokenData } from '../types.js';
|
|
8
|
+
/** hex 값(lowercase) → 토큰 CSS 변수명 배열 역방향 매핑 빌드 — server.ts에서 1회 호출 */
|
|
9
|
+
export declare const buildTokenValueMap: (tokenData: TokenData) => Map<string, string[]>;
|
|
10
|
+
/** inline style + <style> 블록의 CSS 텍스트를 하나로 합침 */
|
|
11
|
+
export declare const extractAllCss: (root: ReturnType<typeof parse>) => string;
|
|
12
|
+
/** 토큰 미사용 + 무효 토큰 감지 */
|
|
13
|
+
export declare const detectTokenIssues: (params: {
|
|
14
|
+
root: ReturnType<typeof parse>;
|
|
15
|
+
tokenData: TokenData;
|
|
16
|
+
tokenValueMap: Map<string, string[]>;
|
|
17
|
+
}) => {
|
|
18
|
+
errors: ComplianceError[];
|
|
19
|
+
tokenUsage: {
|
|
20
|
+
correct: number;
|
|
21
|
+
missing: number;
|
|
22
|
+
invalid: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectTokenIssues = exports.extractAllCss = exports.buildTokenValueMap = void 0;
|
|
4
|
+
// ── server.ts 사전 계산용 ───────────────────────────────────────────────
|
|
5
|
+
/** hex 값(lowercase) → 토큰 CSS 변수명 배열 역방향 매핑 빌드 — server.ts에서 1회 호출 */
|
|
6
|
+
const buildTokenValueMap = (tokenData) => {
|
|
7
|
+
const map = new Map();
|
|
8
|
+
for (const group of tokenData.groups) {
|
|
9
|
+
if (group.category !== 'color')
|
|
10
|
+
continue;
|
|
11
|
+
for (const token of group.tokens) {
|
|
12
|
+
const hex = token.value.toLowerCase();
|
|
13
|
+
const existing = map.get(hex);
|
|
14
|
+
if (existing)
|
|
15
|
+
existing.push(token.name);
|
|
16
|
+
else
|
|
17
|
+
map.set(hex, [token.name]);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
};
|
|
22
|
+
exports.buildTokenValueMap = buildTokenValueMap;
|
|
23
|
+
// ── 상수 ─────────────────────────────────────────────────────────────────
|
|
24
|
+
const HEX_PATTERN_SOURCE = /#([0-9a-fA-F]{3,8})\b/g.source;
|
|
25
|
+
const RGB_PATTERN_SOURCE = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g.source;
|
|
26
|
+
const VAR_PATTERN_SOURCE = /var\(\s*(--[a-zA-Z0-9-]+)(?:\s*,\s*[^)]+)?\s*\)/g.source;
|
|
27
|
+
// ── 순수 헬퍼 ────────────────────────────────────────────────────────────
|
|
28
|
+
/** rgb(r,g,b) → #rrggbb hex 변환 */
|
|
29
|
+
const rgbToHex = (r, g, b) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
30
|
+
/** 3자리 hex를 6자리로 정규화 */
|
|
31
|
+
const normalizeHex = (hex) => {
|
|
32
|
+
const h = hex.toLowerCase().replace('#', '');
|
|
33
|
+
if (h.length === 3)
|
|
34
|
+
return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`;
|
|
35
|
+
return `#${h.slice(0, 6)}`;
|
|
36
|
+
};
|
|
37
|
+
/** var()의 prefix가 토큰 시스템에 속하는지 확인 */
|
|
38
|
+
const hasKnownTokenPrefix = (varName, prefixSet) => {
|
|
39
|
+
for (const prefix of prefixSet) {
|
|
40
|
+
if (varName.startsWith(prefix))
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
/** tokenData에 등록된 토큰명 prefix Set — 커스텀 변수 제외용 */
|
|
46
|
+
const buildTokenPrefixSet = (tokenData) => {
|
|
47
|
+
const prefixes = new Set();
|
|
48
|
+
for (const group of tokenData.groups) {
|
|
49
|
+
for (const token of group.tokens) {
|
|
50
|
+
const stripped = token.name.replace(/^--/, '');
|
|
51
|
+
const segments = stripped.split('-');
|
|
52
|
+
if (segments.length >= 2)
|
|
53
|
+
prefixes.add(`--${segments.slice(0, 2).join('-')}`);
|
|
54
|
+
if (segments.length >= 1)
|
|
55
|
+
prefixes.add(`--${segments[0]}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return prefixes;
|
|
59
|
+
};
|
|
60
|
+
/** 모든 토큰명을 Set으로 수집 */
|
|
61
|
+
const buildTokenNameSet = (tokenData) => {
|
|
62
|
+
const names = new Set();
|
|
63
|
+
for (const group of tokenData.groups) {
|
|
64
|
+
for (const token of group.tokens)
|
|
65
|
+
names.add(token.name);
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
};
|
|
69
|
+
/** inline style + <style> 블록의 CSS 텍스트를 하나로 합침 */
|
|
70
|
+
const extractAllCss = (root) => {
|
|
71
|
+
const parts = [];
|
|
72
|
+
for (const el of root.querySelectorAll('[style]')) {
|
|
73
|
+
const style = el.getAttribute('style');
|
|
74
|
+
if (style)
|
|
75
|
+
parts.push(style);
|
|
76
|
+
}
|
|
77
|
+
for (const styleEl of root.querySelectorAll('style')) {
|
|
78
|
+
parts.push(styleEl.textContent ?? '');
|
|
79
|
+
}
|
|
80
|
+
return parts.join(' ');
|
|
81
|
+
};
|
|
82
|
+
exports.extractAllCss = extractAllCss;
|
|
83
|
+
/** CSS 텍스트에서 하드코딩된 색상값(hex/rgb)을 추출 */
|
|
84
|
+
const collectHardcodedColors = (css) => {
|
|
85
|
+
const colors = [];
|
|
86
|
+
let match;
|
|
87
|
+
const hexPattern = new RegExp(HEX_PATTERN_SOURCE, 'g');
|
|
88
|
+
while ((match = hexPattern.exec(css)) !== null) {
|
|
89
|
+
colors.push({ raw: match[0], normalized: normalizeHex(match[0]) });
|
|
90
|
+
}
|
|
91
|
+
const rgbPattern = new RegExp(RGB_PATTERN_SOURCE, 'g');
|
|
92
|
+
while ((match = rgbPattern.exec(css)) !== null) {
|
|
93
|
+
colors.push({
|
|
94
|
+
raw: match[0],
|
|
95
|
+
normalized: rgbToHex(parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return colors;
|
|
99
|
+
};
|
|
100
|
+
/** token_not_used suggestion 생성 */
|
|
101
|
+
const buildTokenSuggestion = (raw, tokenNames) => tokenNames.length === 1
|
|
102
|
+
? `Use var(${tokenNames[0]}) instead of ${raw}`
|
|
103
|
+
: `Possible tokens: ${tokenNames.map((t) => `var(${t})`).join(', ')}`;
|
|
104
|
+
// ── 검증 함수 ────────────────────────────────────────────────────────────
|
|
105
|
+
/** 하드코딩 색상 → token_not_used 에러 수집 */
|
|
106
|
+
const collectTokenNotUsedErrors = (css, tokenValueMap) => {
|
|
107
|
+
const errors = [];
|
|
108
|
+
let missing = 0;
|
|
109
|
+
for (const color of collectHardcodedColors(css)) {
|
|
110
|
+
const tokenNames = tokenValueMap.get(color.normalized);
|
|
111
|
+
if (!tokenNames)
|
|
112
|
+
continue;
|
|
113
|
+
missing++;
|
|
114
|
+
errors.push({
|
|
115
|
+
type: 'token_not_used',
|
|
116
|
+
target: color.raw,
|
|
117
|
+
component: '(inline-style)',
|
|
118
|
+
message: `Hardcoded color '${color.raw}' should use a design token.`,
|
|
119
|
+
suggestion: buildTokenSuggestion(color.raw, tokenNames),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { errors, missing };
|
|
123
|
+
};
|
|
124
|
+
/** var() 참조 → invalid_token 에러 수집 + correct 카운트 */
|
|
125
|
+
const collectInvalidTokenErrors = (css, tokenNameSet, tokenPrefixSet) => {
|
|
126
|
+
const errors = [];
|
|
127
|
+
let correct = 0;
|
|
128
|
+
let invalid = 0;
|
|
129
|
+
let varMatch;
|
|
130
|
+
const varPattern = new RegExp(VAR_PATTERN_SOURCE, 'g');
|
|
131
|
+
while ((varMatch = varPattern.exec(css)) !== null) {
|
|
132
|
+
const varName = varMatch[1];
|
|
133
|
+
if (!hasKnownTokenPrefix(varName, tokenPrefixSet))
|
|
134
|
+
continue;
|
|
135
|
+
if (tokenNameSet.has(varName)) {
|
|
136
|
+
correct++;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
invalid++;
|
|
140
|
+
errors.push({
|
|
141
|
+
type: 'invalid_token',
|
|
142
|
+
target: `var(${varName})`,
|
|
143
|
+
component: '(inline-style)',
|
|
144
|
+
message: `Token '${varName}' does not exist in the design system.`,
|
|
145
|
+
suggestion: "Call get_design_tokens('color') to see available tokens.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { errors, correct, invalid };
|
|
150
|
+
};
|
|
151
|
+
/** 토큰 미사용 + 무효 토큰 감지 */
|
|
152
|
+
const detectTokenIssues = (params) => {
|
|
153
|
+
const { root, tokenData, tokenValueMap } = params;
|
|
154
|
+
const allCss = (0, exports.extractAllCss)(root);
|
|
155
|
+
const notUsed = collectTokenNotUsedErrors(allCss, tokenValueMap);
|
|
156
|
+
const invalidResult = collectInvalidTokenErrors(allCss, buildTokenNameSet(tokenData), buildTokenPrefixSet(tokenData));
|
|
157
|
+
return {
|
|
158
|
+
errors: [...notUsed.errors, ...invalidResult.errors],
|
|
159
|
+
tokenUsage: { correct: invalidResult.correct, missing: notUsed.missing, invalid: invalidResult.invalid },
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
exports.detectTokenIssues = detectTokenIssues;
|
package/bin/version.d.ts
ADDED