@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,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkCdnInclusion = exports.buildFixedHtml = exports.collectBemErrors = exports.buildRootClassMap = exports.getClassList = void 0;
|
|
4
|
+
// ── 상수 ─────────────────────────────────────────────────────────────────
|
|
5
|
+
const MAX_SIMILAR_SUGGESTIONS = 3;
|
|
6
|
+
// ── 순수 헬퍼 ────────────────────────────────────────────────────────────
|
|
7
|
+
/** 엘리먼트에서 class 목록을 파싱 */
|
|
8
|
+
const getClassList = (el) => el.getAttribute('class')?.split(/\s+/) ?? [];
|
|
9
|
+
exports.getClassList = getClassList;
|
|
10
|
+
/** root class → component name 매핑 빌드 — server.ts에서 1회 호출 */
|
|
11
|
+
const buildRootClassMap = (componentMap) => {
|
|
12
|
+
const rootClassMap = new Map();
|
|
13
|
+
for (const [name, data] of componentMap) {
|
|
14
|
+
for (const cls of data.bemClasses) {
|
|
15
|
+
if (!cls.includes('--') && !cls.includes('__'))
|
|
16
|
+
rootClassMap.set(cls, name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return rootClassMap;
|
|
20
|
+
};
|
|
21
|
+
exports.buildRootClassMap = buildRootClassMap;
|
|
22
|
+
/** ncua- 클래스에서 root class를 결정 (modifier/element 제외, 가장 짧은 것) */
|
|
23
|
+
const findRootClass = (ncuaClasses) => {
|
|
24
|
+
const roots = ncuaClasses.filter((c) => !c.includes('--') && !c.includes('__'));
|
|
25
|
+
if (roots.length === 0)
|
|
26
|
+
return undefined;
|
|
27
|
+
return roots.sort((a, b) => a.length - b.length)[0];
|
|
28
|
+
};
|
|
29
|
+
/** BEM 구분자(-- 또는 __)의 첫 위치를 반환. 없으면 -1 */
|
|
30
|
+
const findFirstSeparatorIndex = (dashIdx, underIdx) => {
|
|
31
|
+
if (dashIdx >= 0 && underIdx >= 0)
|
|
32
|
+
return Math.min(dashIdx, underIdx);
|
|
33
|
+
if (dashIdx >= 0)
|
|
34
|
+
return dashIdx;
|
|
35
|
+
return underIdx;
|
|
36
|
+
};
|
|
37
|
+
/** modifier/element 클래스에서 root class를 추론 */
|
|
38
|
+
const inferRootClass = (ncuaClasses, rootClassMap) => {
|
|
39
|
+
for (const cls of ncuaClasses) {
|
|
40
|
+
const sepIdx = findFirstSeparatorIndex(cls.indexOf('--'), cls.indexOf('__'));
|
|
41
|
+
if (sepIdx > 0) {
|
|
42
|
+
const candidate = cls.substring(0, sepIdx);
|
|
43
|
+
if (rootClassMap.has(candidate))
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
/** 문자열 유사도 — 공통 접두사 길이 기반 */
|
|
50
|
+
const prefixSimilarity = (a, b) => {
|
|
51
|
+
let i = 0;
|
|
52
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
53
|
+
i++;
|
|
54
|
+
return i;
|
|
55
|
+
};
|
|
56
|
+
/** 유사도 상위 N개 제안 — modifier 오류면 modifier만, element 오류면 element만 */
|
|
57
|
+
const suggestSimilar = (invalidClass, validClasses, max = MAX_SIMILAR_SUGGESTIONS) => {
|
|
58
|
+
const isElement = invalidClass.includes('__');
|
|
59
|
+
return validClasses
|
|
60
|
+
.filter((c) => (isElement ? c.includes('__') : c.includes('--')))
|
|
61
|
+
.map((c) => ({ class: c, score: prefixSimilarity(invalidClass, c) }))
|
|
62
|
+
.sort((a, b) => b.score - a.score)
|
|
63
|
+
.slice(0, max)
|
|
64
|
+
.map((item) => item.class);
|
|
65
|
+
};
|
|
66
|
+
/** unknown component 에러 수집 */
|
|
67
|
+
const reportUnknownClasses = (ncuaClasses, errors, invalidClassesSet) => {
|
|
68
|
+
for (const cls of ncuaClasses) {
|
|
69
|
+
errors.push({
|
|
70
|
+
type: 'unknown_component',
|
|
71
|
+
class: cls,
|
|
72
|
+
component: 'unknown',
|
|
73
|
+
message: `Unknown component class '${cls}'. No matching component found.`,
|
|
74
|
+
suggestion: 'Use list_components to see all available components.',
|
|
75
|
+
});
|
|
76
|
+
invalidClassesSet.add(cls);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/** BEM 클래스 유효성 검증 */
|
|
80
|
+
const validateBemClasses = (ncuaClasses, validSet, componentName, bemClasses, errors, invalidClassesSet) => {
|
|
81
|
+
for (const cls of ncuaClasses) {
|
|
82
|
+
if (validSet.has(cls))
|
|
83
|
+
continue;
|
|
84
|
+
const similar = suggestSimilar(cls, bemClasses);
|
|
85
|
+
errors.push({
|
|
86
|
+
type: cls.includes('__') ? 'invalid_element' : 'invalid_modifier',
|
|
87
|
+
class: cls,
|
|
88
|
+
component: componentName,
|
|
89
|
+
message: `Invalid BEM class '${cls}' for component '${componentName}'.`,
|
|
90
|
+
suggestion: similar.length > 0 ? `Similar: ${similar.join(', ')}` : 'Use get_component_props to see valid BEM classes.',
|
|
91
|
+
});
|
|
92
|
+
invalidClassesSet.add(cls);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
// ── BEM 검증 엔트리 함수 ────────────────────────────────────────────────
|
|
96
|
+
/** 엘리먼트별 BEM 클래스 검증 → errors + warnings + invalidClasses 수집 */
|
|
97
|
+
const collectBemErrors = (params) => {
|
|
98
|
+
const { elements, rootClassMap, componentMap } = params;
|
|
99
|
+
const errors = [];
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const invalidClassesSet = new Set();
|
|
102
|
+
const validSetCache = new Map();
|
|
103
|
+
let foundNcua = false;
|
|
104
|
+
for (const el of elements) {
|
|
105
|
+
const classes = (0, exports.getClassList)(el);
|
|
106
|
+
const ncuaClasses = classes.filter((c) => c.startsWith('ncua-'));
|
|
107
|
+
if (ncuaClasses.length === 0)
|
|
108
|
+
continue;
|
|
109
|
+
foundNcua = true;
|
|
110
|
+
let rootClass = findRootClass(ncuaClasses);
|
|
111
|
+
if (!rootClass || !rootClassMap.has(rootClass)) {
|
|
112
|
+
const inferred = inferRootClass(ncuaClasses, rootClassMap);
|
|
113
|
+
if (inferred)
|
|
114
|
+
rootClass = inferred;
|
|
115
|
+
}
|
|
116
|
+
collectMixedNamespaceWarning(classes, ncuaClasses, rootClass, rootClassMap, warnings);
|
|
117
|
+
collectMissingRootClassError(ncuaClasses, rootClass, rootClassMap, errors);
|
|
118
|
+
if (!rootClass || !rootClassMap.has(rootClass)) {
|
|
119
|
+
reportUnknownClasses(ncuaClasses, errors, invalidClassesSet);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const componentName = rootClassMap.get(rootClass);
|
|
123
|
+
const component = componentMap.get(componentName);
|
|
124
|
+
if (!validSetCache.has(componentName))
|
|
125
|
+
validSetCache.set(componentName, new Set(component.bemClasses));
|
|
126
|
+
validateBemClasses(ncuaClasses, validSetCache.get(componentName), componentName, component.bemClasses, errors, invalidClassesSet);
|
|
127
|
+
}
|
|
128
|
+
return { errors, warnings, invalidClassesSet, hasNcuaClasses: foundNcua };
|
|
129
|
+
};
|
|
130
|
+
exports.collectBemErrors = collectBemErrors;
|
|
131
|
+
/** mixed_namespace 경고 수집 */
|
|
132
|
+
const collectMixedNamespaceWarning = (classes, ncuaClasses, rootClass, rootClassMap, warnings) => {
|
|
133
|
+
const customClasses = classes.filter((c) => !c.startsWith('ncua-'));
|
|
134
|
+
if (customClasses.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
const compName = rootClass && rootClassMap.has(rootClass) ? rootClassMap.get(rootClass) : 'unknown';
|
|
137
|
+
warnings.push({
|
|
138
|
+
type: 'mixed_namespace',
|
|
139
|
+
class: ncuaClasses[0],
|
|
140
|
+
component: compName,
|
|
141
|
+
message: `Element mixes NCUA classes (${ncuaClasses.join(', ')}) with custom classes (${customClasses.join(', ')}).`,
|
|
142
|
+
suggestion: 'Keep NCUA components isolated. Use a separate wrapper for custom styling.',
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
/** missing_root_class 에러 수집 */
|
|
146
|
+
const collectMissingRootClassError = (ncuaClasses, rootClass, rootClassMap, errors) => {
|
|
147
|
+
const hasModifiers = ncuaClasses.some((c) => c.includes('--'));
|
|
148
|
+
const hasRootInElement = ncuaClasses.some((c) => !c.includes('--') && !c.includes('__'));
|
|
149
|
+
const isModifierWithoutRoot = hasModifiers && !hasRootInElement && rootClass != null && rootClassMap.has(rootClass);
|
|
150
|
+
if (!isModifierWithoutRoot)
|
|
151
|
+
return;
|
|
152
|
+
errors.push({
|
|
153
|
+
type: 'missing_root_class',
|
|
154
|
+
class: rootClass,
|
|
155
|
+
component: rootClassMap.get(rootClass),
|
|
156
|
+
message: `Root class '${rootClass}' is missing. Modifiers/elements require their root class.`,
|
|
157
|
+
suggestion: `Add '${rootClass}' class to the same element.`,
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
/** 잘못된 클래스를 제거하여 fixed_html 생성 */
|
|
161
|
+
const buildFixedHtml = (root, invalidClassesSet) => {
|
|
162
|
+
for (const el of root.querySelectorAll('[class]')) {
|
|
163
|
+
const classes = (0, exports.getClassList)(el);
|
|
164
|
+
const filtered = classes.filter((c) => !invalidClassesSet.has(c));
|
|
165
|
+
if (filtered.length > 0) {
|
|
166
|
+
el.setAttribute('class', filtered.join(' '));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
el.removeAttribute('class');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return root.toString();
|
|
173
|
+
};
|
|
174
|
+
exports.buildFixedHtml = buildFixedHtml;
|
|
175
|
+
/** CDN CSS/JS 링크 포함 여부 검증 */
|
|
176
|
+
const checkCdnInclusion = (html, cdnMeta) => {
|
|
177
|
+
const errors = [];
|
|
178
|
+
if (!html.includes(cdnMeta.css)) {
|
|
179
|
+
errors.push({
|
|
180
|
+
type: 'missing_cdn',
|
|
181
|
+
class: '(page-level)',
|
|
182
|
+
component: '(page-level)',
|
|
183
|
+
message: 'HTML contains NCUA components but is missing the required CDN CSS link.',
|
|
184
|
+
suggestion: `Add <link href="${cdnMeta.css}" rel="stylesheet">`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (!html.includes(cdnMeta.js)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
type: 'missing_cdn',
|
|
190
|
+
class: '(page-level)',
|
|
191
|
+
component: '(page-level)',
|
|
192
|
+
message: 'HTML contains NCUA components but is missing the required CDN JS link.',
|
|
193
|
+
suggestion: `Add <script src="${cdnMeta.js}"></script>`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return errors;
|
|
197
|
+
};
|
|
198
|
+
exports.checkCdnInclusion = checkCdnInclusion;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compliance 검증 순수 함수 — NCUA 컴포넌트 누락, 커스텀 분리 검증, 준수도 점수
|
|
3
|
+
*
|
|
4
|
+
* 토큰 검증은 tokenValidator.ts에 분리.
|
|
5
|
+
*/
|
|
6
|
+
import { parse } from 'node-html-parser';
|
|
7
|
+
import type { ComponentData, ComplianceRulesData, ComplianceError, ComplianceSummary } from '../types.js';
|
|
8
|
+
import type { ValidationError } from './bemValidator.js';
|
|
9
|
+
export { detectTokenIssues } from './tokenValidator.js';
|
|
10
|
+
/** NCUA 컴포넌트 누락 감지 — 태그 인덱스로 O(n) 매칭 */
|
|
11
|
+
export declare const detectNcuaOmission: (params: {
|
|
12
|
+
root: ReturnType<typeof parse>;
|
|
13
|
+
complianceRules: ComplianceRulesData;
|
|
14
|
+
componentMap: Map<string, ComponentData>;
|
|
15
|
+
}) => {
|
|
16
|
+
errors: ComplianceError[];
|
|
17
|
+
ncuaUsage: {
|
|
18
|
+
used: number;
|
|
19
|
+
available: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/** NCUA 컴포넌트 내부 커스텀 스타일 감지 */
|
|
23
|
+
export declare const detectCustomNotSeparated: (params: {
|
|
24
|
+
root: ReturnType<typeof parse>;
|
|
25
|
+
rootClassMap: Map<string, string>;
|
|
26
|
+
warnings: ValidationError[];
|
|
27
|
+
}) => {
|
|
28
|
+
errors: ComplianceError[];
|
|
29
|
+
customSeparation: {
|
|
30
|
+
clean: number;
|
|
31
|
+
violated: number;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** 준수도 점수 + 요약 빌드 */
|
|
35
|
+
export declare const buildComplianceSummary: (params: {
|
|
36
|
+
ncuaErrors: ComplianceError[];
|
|
37
|
+
tokenErrors: ComplianceError[];
|
|
38
|
+
customErrors: ComplianceError[];
|
|
39
|
+
ncuaUsage: {
|
|
40
|
+
used: number;
|
|
41
|
+
available: number;
|
|
42
|
+
};
|
|
43
|
+
tokenUsage: {
|
|
44
|
+
correct: number;
|
|
45
|
+
missing: number;
|
|
46
|
+
invalid: number;
|
|
47
|
+
};
|
|
48
|
+
customSeparation: {
|
|
49
|
+
clean: number;
|
|
50
|
+
violated: number;
|
|
51
|
+
};
|
|
52
|
+
}) => ComplianceSummary;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildComplianceSummary = exports.detectCustomNotSeparated = exports.detectNcuaOmission = exports.detectTokenIssues = void 0;
|
|
4
|
+
const bemValidator_js_1 = require("./bemValidator.js");
|
|
5
|
+
var tokenValidator_js_1 = require("./tokenValidator.js");
|
|
6
|
+
Object.defineProperty(exports, "detectTokenIssues", { enumerable: true, get: function () { return tokenValidator_js_1.detectTokenIssues; } });
|
|
7
|
+
// ── 상수 ─────────────────────────────────────────────────────────────────
|
|
8
|
+
const MAX_COMPLIANCE_ERRORS = 10;
|
|
9
|
+
const NCUA_NOT_USED_MEDIUM_WEIGHT = 0.5;
|
|
10
|
+
const COMPLIANCE_WEIGHTS = {
|
|
11
|
+
ncua_not_used: 1.0,
|
|
12
|
+
token_not_used: 0.5,
|
|
13
|
+
invalid_token: 1.0,
|
|
14
|
+
custom_not_separated: 1.0,
|
|
15
|
+
};
|
|
16
|
+
/** 규칙의 hints가 요소 속성과 매칭되는지 확인 */
|
|
17
|
+
const matchesHints = (el, hints) => {
|
|
18
|
+
if (!hints)
|
|
19
|
+
return true;
|
|
20
|
+
for (const hint of hints) {
|
|
21
|
+
const attrValue = el.getAttribute(hint.attr);
|
|
22
|
+
if (!attrValue || !hint.values.includes(attrValue.toLowerCase()))
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
/** 태그명 → 해당 태그의 규칙 배열 인덱스 (§ 5.3 O(n) 전환) */
|
|
28
|
+
const buildTagRuleIndex = (rules, componentMap) => {
|
|
29
|
+
const index = new Map();
|
|
30
|
+
for (const rule of rules) {
|
|
31
|
+
if (!componentMap.has(rule.ncuaComponent))
|
|
32
|
+
continue;
|
|
33
|
+
const existing = index.get(rule.match.tag);
|
|
34
|
+
if (existing)
|
|
35
|
+
existing.push(rule);
|
|
36
|
+
else
|
|
37
|
+
index.set(rule.match.tag, [rule]);
|
|
38
|
+
}
|
|
39
|
+
return index;
|
|
40
|
+
};
|
|
41
|
+
/** ncua- root class → compliance rule의 ncuaComponent 매핑 */
|
|
42
|
+
const buildNcuaRootToComponentMap = (complianceRules, componentMap) => {
|
|
43
|
+
const map = new Map();
|
|
44
|
+
for (const rule of complianceRules.patterns) {
|
|
45
|
+
const component = componentMap.get(rule.ncuaComponent);
|
|
46
|
+
if (!component)
|
|
47
|
+
continue;
|
|
48
|
+
for (const cls of component.bemClasses) {
|
|
49
|
+
if (!cls.includes('--') && !cls.includes('__'))
|
|
50
|
+
map.set(cls, rule.ncuaComponent);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
};
|
|
55
|
+
/** compliance rules 중 componentMap에 실제 존재하는 고유 컴포넌트 수 */
|
|
56
|
+
const countAvailableComponents = (patterns, componentMap) => {
|
|
57
|
+
const available = new Set();
|
|
58
|
+
for (const rule of patterns) {
|
|
59
|
+
if (componentMap.has(rule.ncuaComponent))
|
|
60
|
+
available.add(rule.ncuaComponent);
|
|
61
|
+
}
|
|
62
|
+
return available.size;
|
|
63
|
+
};
|
|
64
|
+
// ── 검증 함수 ────────────────────────────────────────────────────────────
|
|
65
|
+
/** ncua 클래스를 가진 요소에서 사용 중인 컴포넌트 수집 */
|
|
66
|
+
const collectUsedComponents = (el, ncuaRootMap, usedComponents) => {
|
|
67
|
+
for (const cls of (0, bemValidator_js_1.getClassList)(el)) {
|
|
68
|
+
const comp = ncuaRootMap.get(cls);
|
|
69
|
+
if (comp)
|
|
70
|
+
usedComponents.add(comp);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/** 네이티브 HTML 요소에서 ncua_not_used 에러 매칭 */
|
|
74
|
+
const matchNativeElement = (el, tagIndex) => {
|
|
75
|
+
const tag = el.tagName?.toLowerCase();
|
|
76
|
+
if (!tag)
|
|
77
|
+
return undefined;
|
|
78
|
+
const candidateRules = tagIndex.get(tag);
|
|
79
|
+
if (!candidateRules)
|
|
80
|
+
return undefined;
|
|
81
|
+
for (const rule of candidateRules) {
|
|
82
|
+
if (!matchesHints(el, rule.match.hints))
|
|
83
|
+
continue;
|
|
84
|
+
return {
|
|
85
|
+
type: 'ncua_not_used',
|
|
86
|
+
target: `<${tag}>`,
|
|
87
|
+
component: rule.ncuaComponent,
|
|
88
|
+
message: `Native <${tag}> used instead of NCUA '${rule.ncuaComponent}' component.`,
|
|
89
|
+
suggestion: `Use NCUA '${rule.ncuaComponent}' component. Call search_component('${rule.ncuaComponent}') for usage.`,
|
|
90
|
+
...(rule.confidence === 'medium' && { severity: 'warning' }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
/** NCUA 컴포넌트 누락 감지 — 태그 인덱스로 O(n) 매칭 */
|
|
96
|
+
const detectNcuaOmission = (params) => {
|
|
97
|
+
const { root, complianceRules, componentMap } = params;
|
|
98
|
+
const errors = [];
|
|
99
|
+
const usedComponents = new Set();
|
|
100
|
+
const tagIndex = buildTagRuleIndex(complianceRules.patterns, componentMap);
|
|
101
|
+
const ncuaRootMap = buildNcuaRootToComponentMap(complianceRules, componentMap);
|
|
102
|
+
for (const el of root.querySelectorAll('*')) {
|
|
103
|
+
if ((0, bemValidator_js_1.getClassList)(el).some((c) => c.startsWith('ncua-'))) {
|
|
104
|
+
collectUsedComponents(el, ncuaRootMap, usedComponents);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const error = matchNativeElement(el, tagIndex);
|
|
108
|
+
if (error)
|
|
109
|
+
errors.push(error);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
errors,
|
|
113
|
+
ncuaUsage: {
|
|
114
|
+
used: usedComponents.size,
|
|
115
|
+
available: countAvailableComponents(complianceRules.patterns, componentMap),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
exports.detectNcuaOmission = detectNcuaOmission;
|
|
120
|
+
/** NCUA 컴포넌트 내부 커스텀 스타일 감지 */
|
|
121
|
+
const detectCustomNotSeparated = (params) => {
|
|
122
|
+
const { root, rootClassMap, warnings } = params;
|
|
123
|
+
const errors = [];
|
|
124
|
+
let violated = 0;
|
|
125
|
+
let clean = 0;
|
|
126
|
+
const suppressedClasses = new Set();
|
|
127
|
+
for (const el of root.querySelectorAll('[class]')) {
|
|
128
|
+
const classes = (0, bemValidator_js_1.getClassList)(el);
|
|
129
|
+
const ncuaClasses = classes.filter((c) => c.startsWith('ncua-'));
|
|
130
|
+
if (ncuaClasses.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
const target = ncuaClasses[0];
|
|
133
|
+
const hasInlineStyle = !!el.getAttribute('style');
|
|
134
|
+
const isBemInternal = ncuaClasses.some((c) => c.includes('__'));
|
|
135
|
+
const hasCustomOverride = isBemInternal && classes.some((c) => !c.startsWith('ncua-'));
|
|
136
|
+
if (hasInlineStyle) {
|
|
137
|
+
violated++;
|
|
138
|
+
errors.push(buildCustomError(target, rootClassMap, 'inline'));
|
|
139
|
+
}
|
|
140
|
+
else if (hasCustomOverride) {
|
|
141
|
+
violated++;
|
|
142
|
+
suppressedClasses.add(target);
|
|
143
|
+
errors.push(buildCustomError(target, rootClassMap, 'override'));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
clean++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
suppressMixedNamespaceWarnings(warnings, suppressedClasses);
|
|
150
|
+
return { errors, customSeparation: { clean, violated } };
|
|
151
|
+
};
|
|
152
|
+
exports.detectCustomNotSeparated = detectCustomNotSeparated;
|
|
153
|
+
/** custom_not_separated 에러 빌드 */
|
|
154
|
+
const buildCustomError = (target, rootClassMap, kind) => {
|
|
155
|
+
const rootClass = kind === 'inline' ? target.split('--')[0].split('__')[0] : target.split('__')[0];
|
|
156
|
+
const message = kind === 'inline'
|
|
157
|
+
? `Inline style on NCUA component element '${target}'. NCUA components should not have inline styles.`
|
|
158
|
+
: `Custom class override on NCUA BEM internal element '${target}'. Do not add custom classes to BEM sub-elements.`;
|
|
159
|
+
return {
|
|
160
|
+
type: 'custom_not_separated',
|
|
161
|
+
target,
|
|
162
|
+
component: rootClassMap.get(rootClass) ?? 'unknown',
|
|
163
|
+
message,
|
|
164
|
+
suggestion: 'Use a separate wrapper outside the NCUA component for custom styling.',
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/** mixed_namespace warnings 중 suppress 대상을 제거 */
|
|
168
|
+
const suppressMixedNamespaceWarnings = (warnings, suppressedClasses) => {
|
|
169
|
+
if (suppressedClasses.size === 0)
|
|
170
|
+
return;
|
|
171
|
+
for (let i = warnings.length - 1; i >= 0; i--) {
|
|
172
|
+
if (warnings[i].type === 'mixed_namespace' && suppressedClasses.has(warnings[i].class)) {
|
|
173
|
+
warnings.splice(i, 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** 준수도 점수 + 요약 빌드 */
|
|
178
|
+
const buildComplianceSummary = (params) => {
|
|
179
|
+
const { ncuaErrors, tokenErrors, customErrors, ncuaUsage, tokenUsage, customSeparation } = params;
|
|
180
|
+
const allErrors = [...ncuaErrors, ...tokenErrors, ...customErrors];
|
|
181
|
+
let weightedViolations = 0;
|
|
182
|
+
for (const err of allErrors) {
|
|
183
|
+
const isMedium = err.type === 'ncua_not_used' && err.severity === 'warning';
|
|
184
|
+
weightedViolations += isMedium ? NCUA_NOT_USED_MEDIUM_WEIGHT : COMPLIANCE_WEIGHTS[err.type];
|
|
185
|
+
}
|
|
186
|
+
const totalCheckpoints = ncuaUsage.available +
|
|
187
|
+
(tokenUsage.correct + tokenUsage.missing + tokenUsage.invalid) +
|
|
188
|
+
(customSeparation.clean + customSeparation.violated);
|
|
189
|
+
const score = totalCheckpoints === 0 ? 1.0 : Math.max(0, Math.min(1, 1 - weightedViolations / totalCheckpoints));
|
|
190
|
+
return {
|
|
191
|
+
score: Math.round(score * 100) / 100,
|
|
192
|
+
errors: allErrors.slice(0, MAX_COMPLIANCE_ERRORS),
|
|
193
|
+
ncuaUsage,
|
|
194
|
+
tokenUsage,
|
|
195
|
+
customSeparation,
|
|
196
|
+
totalViolations: allErrors.length,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
exports.buildComplianceSummary = buildComplianceSummary;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentData, IconData, TokenData, ComplianceRulesData } from '../types.js';
|
|
2
|
+
export declare const DEFAULT_DATA_DIR: string;
|
|
3
|
+
export interface CdnMeta {
|
|
4
|
+
version: string;
|
|
5
|
+
css: string;
|
|
6
|
+
js: string;
|
|
7
|
+
}
|
|
8
|
+
export interface IconMeta {
|
|
9
|
+
packageName: string;
|
|
10
|
+
version: string;
|
|
11
|
+
cdn: string;
|
|
12
|
+
}
|
|
13
|
+
/** data/ 디렉토리의 JSON 파일을 읽어 componentMap으로 반환 */
|
|
14
|
+
export declare const loadComponentsFromDir: (dataDir: string) => Map<string, ComponentData>;
|
|
15
|
+
/** data/_icons.json을 읽어 IconData로 반환 (파일 없으면 빈 데이터) */
|
|
16
|
+
export declare const loadIconData: (dataDir: string) => IconData;
|
|
17
|
+
/** _meta.json 파싱 결과 */
|
|
18
|
+
export interface MetaData {
|
|
19
|
+
cdn: CdnMeta | null;
|
|
20
|
+
icon: IconMeta | null;
|
|
21
|
+
}
|
|
22
|
+
/** data/_meta.json에서 CDN + icon 메타를 1회 읽기로 로드 */
|
|
23
|
+
export declare const loadMeta: (dataDir: string) => MetaData;
|
|
24
|
+
/** data/_tokens.json을 읽어 TokenData로 반환 (파일 없으면 빈 데이터) */
|
|
25
|
+
export declare const loadTokenData: (dataDir: string) => TokenData;
|
|
26
|
+
/** definitions/compliance-rules.json을 로드하여 ComplianceRulesData로 반환 (파일 없으면 null) */
|
|
27
|
+
export declare const loadComplianceRules: (definitionsDir: string) => ComplianceRulesData | null;
|
|
28
|
+
/** definitions/instructions.md를 로드하여 문자열 반환 */
|
|
29
|
+
export declare const loadInstructions: (definitionsDir: string) => string;
|
|
30
|
+
/** componentMap에서 이름으로 단일 컴포넌트 조회 */
|
|
31
|
+
export declare const getComponent: (map: Map<string, ComponentData>, name: string) => ComponentData | undefined;
|
|
32
|
+
/** componentMap의 모든 컴포넌트를 배열로 반환 */
|
|
33
|
+
export declare const getAllComponents: (map: Map<string, ComponentData>) => ComponentData[];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getAllComponents = exports.getComponent = exports.loadInstructions = exports.loadComplianceRules = exports.loadTokenData = exports.loadMeta = exports.loadIconData = exports.loadComponentsFromDir = exports.DEFAULT_DATA_DIR = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* dataLoader — mcp/data/*.json + definitions/ 파일을 읽어 반환
|
|
9
|
+
*
|
|
10
|
+
* 함수 분류:
|
|
11
|
+
* loadComponentsFromDir, loadIconData, loadCdnMeta, loadInstructions → 부수효과 (fs I/O)
|
|
12
|
+
* getComponent, getAllComponents → 순수 함수 (Map 조회)
|
|
13
|
+
*
|
|
14
|
+
* Map 소유권은 server.ts가 가진다. 이 파일은 상태를 보유하지 않는다.
|
|
15
|
+
*/
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const logger_js_1 = require("./logger.js");
|
|
19
|
+
const response_js_1 = require("./response.js");
|
|
20
|
+
exports.DEFAULT_DATA_DIR = path_1.default.resolve(__dirname, '../../data');
|
|
21
|
+
// ── 검증 헬퍼 (§ 3.8 복잡한 조건을 의도가 드러나는 이름으로 분리) ──────────
|
|
22
|
+
/** JSON.parse 결과가 name: string 필드를 가진 컴포넌트 객체인지 검증 */
|
|
23
|
+
const isValidComponentJson = (data) => !!data && typeof data === 'object' && 'name' in data && typeof data.name === 'string';
|
|
24
|
+
/** JSON.parse 결과가 icons 배열을 가진 아이콘 데이터인지 검증 */
|
|
25
|
+
const isValidIconJson = (data) => !!data && typeof data === 'object' && 'icons' in data && Array.isArray(data.icons);
|
|
26
|
+
// ── 부수효과 함수 (fs I/O) ────────────────────────────────────────────────
|
|
27
|
+
/** data/ 디렉토리의 JSON 파일을 읽어 componentMap으로 반환 */
|
|
28
|
+
const loadComponentsFromDir = (dataDir) => {
|
|
29
|
+
if (!fs_1.default.existsSync(dataDir)) {
|
|
30
|
+
logger_js_1.logger.error(`mcp/data/ 디렉토리가 없습니다: ${dataDir}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const jsonFiles = fs_1.default.readdirSync(dataDir).filter((f) => f.endsWith('.json') && !f.startsWith('_'));
|
|
34
|
+
if (jsonFiles.length === 0) {
|
|
35
|
+
logger_js_1.logger.error(`mcp/data/ 디렉토리에 JSON 파일이 없습니다: ${dataDir}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const map = new Map();
|
|
39
|
+
for (const file of jsonFiles) {
|
|
40
|
+
const filePath = path_1.default.join(dataDir, file);
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (!isValidComponentJson(parsed)) {
|
|
45
|
+
throw new Error('name 필드가 없거나 올바르지 않습니다');
|
|
46
|
+
}
|
|
47
|
+
const component = parsed;
|
|
48
|
+
map.set(component.name, component);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
logger_js_1.logger.error(`JSON 파싱 실패 (${file}): ${(0, response_js_1.toErrorMessage)(err)}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
logger_js_1.logger.info(`컴포넌트 ${map.size}개 로딩 완료`);
|
|
56
|
+
return map;
|
|
57
|
+
};
|
|
58
|
+
exports.loadComponentsFromDir = loadComponentsFromDir;
|
|
59
|
+
/** data/_icons.json을 읽어 IconData로 반환 (파일 없으면 빈 데이터) */
|
|
60
|
+
const loadIconData = (dataDir) => {
|
|
61
|
+
const iconPath = path_1.default.join(dataDir, '_icons.json');
|
|
62
|
+
if (!fs_1.default.existsSync(iconPath)) {
|
|
63
|
+
logger_js_1.logger.error(`_icons.json이 없습니다: ${iconPath}`);
|
|
64
|
+
return { totalCount: 0, fillCount: 0, icons: [] };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs_1.default.readFileSync(iconPath, 'utf-8');
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!isValidIconJson(parsed)) {
|
|
70
|
+
throw new Error('icons 배열이 없습니다');
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger_js_1.logger.error(`_icons.json 파싱 실패: ${(0, response_js_1.toErrorMessage)(err)}`);
|
|
76
|
+
return { totalCount: 0, fillCount: 0, icons: [] };
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
exports.loadIconData = loadIconData;
|
|
80
|
+
/** data/_meta.json에서 CDN + icon 메타를 1회 읽기로 로드 */
|
|
81
|
+
const loadMeta = (dataDir) => {
|
|
82
|
+
const metaPath = path_1.default.join(dataDir, '_meta.json');
|
|
83
|
+
if (!fs_1.default.existsSync(metaPath))
|
|
84
|
+
return { cdn: null, icon: null };
|
|
85
|
+
try {
|
|
86
|
+
const raw = fs_1.default.readFileSync(metaPath, 'utf-8');
|
|
87
|
+
const meta = JSON.parse(raw);
|
|
88
|
+
return {
|
|
89
|
+
cdn: isValidCdnMeta(meta.cdn) ? meta.cdn : null,
|
|
90
|
+
icon: isValidIconMeta(meta.icon) ? meta.icon : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
logger_js_1.logger.error(`_meta.json 파싱 실패: ${(0, response_js_1.toErrorMessage)(err)}`);
|
|
95
|
+
return { cdn: null, icon: null };
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
exports.loadMeta = loadMeta;
|
|
99
|
+
/** CdnMeta 스키마 검증 */
|
|
100
|
+
const isValidCdnMeta = (data) => !!data &&
|
|
101
|
+
typeof data === 'object' &&
|
|
102
|
+
'version' in data &&
|
|
103
|
+
'css' in data &&
|
|
104
|
+
'js' in data &&
|
|
105
|
+
typeof data.version === 'string';
|
|
106
|
+
/** IconMeta 스키마 검증 */
|
|
107
|
+
const isValidIconMeta = (data) => !!data &&
|
|
108
|
+
typeof data === 'object' &&
|
|
109
|
+
'packageName' in data &&
|
|
110
|
+
'version' in data &&
|
|
111
|
+
typeof data.packageName === 'string';
|
|
112
|
+
/** data/_tokens.json을 읽어 TokenData로 반환 (파일 없으면 빈 데이터) */
|
|
113
|
+
const loadTokenData = (dataDir) => {
|
|
114
|
+
const tokenPath = path_1.default.join(dataDir, '_tokens.json');
|
|
115
|
+
if (!fs_1.default.existsSync(tokenPath)) {
|
|
116
|
+
logger_js_1.logger.error(`_tokens.json이 없습니다: ${tokenPath}`);
|
|
117
|
+
return { totalCount: 0, categories: [], groups: [] };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const raw = fs_1.default.readFileSync(tokenPath, 'utf-8');
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (!isValidTokenJson(parsed)) {
|
|
123
|
+
throw new Error('totalCount 또는 groups 배열이 없습니다');
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
logger_js_1.logger.error(`_tokens.json 파싱 실패: ${(0, response_js_1.toErrorMessage)(err)}`);
|
|
129
|
+
return { totalCount: 0, categories: [], groups: [] };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
exports.loadTokenData = loadTokenData;
|
|
133
|
+
/** TokenData 스키마 검증 */
|
|
134
|
+
const isValidTokenJson = (data) => !!data &&
|
|
135
|
+
typeof data === 'object' &&
|
|
136
|
+
'totalCount' in data &&
|
|
137
|
+
'groups' in data &&
|
|
138
|
+
Array.isArray(data.groups);
|
|
139
|
+
/** compliance-rules.json 스키마 검증 */
|
|
140
|
+
const isValidComplianceRulesJson = (data) => !!data && typeof data === 'object' && 'patterns' in data && Array.isArray(data.patterns);
|
|
141
|
+
/** definitions/compliance-rules.json을 로드하여 ComplianceRulesData로 반환 (파일 없으면 null) */
|
|
142
|
+
const loadComplianceRules = (definitionsDir) => {
|
|
143
|
+
const filePath = path_1.default.resolve(definitionsDir, 'compliance-rules.json');
|
|
144
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
145
|
+
logger_js_1.logger.info('compliance-rules.json이 없습니다 — compliance 검증 비활성화');
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const raw = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
if (!isValidComplianceRulesJson(parsed)) {
|
|
152
|
+
throw new Error('patterns 배열이 없습니다');
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
logger_js_1.logger.error(`compliance-rules.json 파싱 실패: ${(0, response_js_1.toErrorMessage)(err)}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
exports.loadComplianceRules = loadComplianceRules;
|
|
162
|
+
/** definitions/instructions.md를 로드하여 문자열 반환 */
|
|
163
|
+
const loadInstructions = (definitionsDir) => {
|
|
164
|
+
const instructionsPath = path_1.default.resolve(definitionsDir, 'instructions.md');
|
|
165
|
+
return fs_1.default.readFileSync(instructionsPath, 'utf-8').trim();
|
|
166
|
+
};
|
|
167
|
+
exports.loadInstructions = loadInstructions;
|
|
168
|
+
// ── 순수 함수 (외부 상태 없음, Map을 파라미터로 받음) ──────────────────────
|
|
169
|
+
/** componentMap에서 이름으로 단일 컴포넌트 조회 */
|
|
170
|
+
const getComponent = (map, name) => map.get(name);
|
|
171
|
+
exports.getComponent = getComponent;
|
|
172
|
+
/** componentMap의 모든 컴포넌트를 배열로 반환 */
|
|
173
|
+
const getAllComponents = (map) => Array.from(map.values());
|
|
174
|
+
exports.getAllComponents = getAllComponents;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM + React 런타임 초기화 — jsdom 환경 + createPortal mock + React SSR
|
|
3
|
+
*
|
|
4
|
+
* Action (부수효과): globalThis를 변조한다. server.ts 또는 테스트 beforeAll에서 1회 호출.
|
|
5
|
+
* 이 파일로 추출하여 server.ts/테스트 간 코드 중복을 제거한다 (§ 3.9).
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactRuntime } from '../types.js';
|
|
8
|
+
/** jsdom + React SSR 환경을 초기화하고 ReactRuntime을 반환 */
|
|
9
|
+
export declare const setupDomEnvironment: () => ReactRuntime;
|