@ncds/ui-admin-mcp 1.0.0-alpha.11 → 1.0.0-alpha.13
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/bin/components.bundle.js +1 -1
- package/bin/definitions/instructions.md +16 -0
- package/bin/server.mjs +0 -0
- package/bin/tools/getComponentProps.js +1 -0
- package/bin/tools/renderToHtml.js +259 -72
- package/bin/types.d.ts +7 -0
- package/bin/utils/bemValidator.d.ts +1 -1
- package/bin/utils/compliance.js +3 -1
- package/bin/utils/dataLoader.d.ts +0 -1
- package/bin/utils/dataLoader.js +8 -16
- package/bin/utils/domEnvironment.js +1 -0
- package/bin/utils/fuzzyMatch.d.ts +4 -0
- package/bin/utils/fuzzyMatch.js +12 -3
- package/bin/utils/logger.d.ts +5 -5
- package/bin/utils/logger.js +5 -5
- package/data/_meta.json +4 -5
- package/data/badge-group.json +9 -6
- package/data/badge.json +32 -16
- package/data/bread-crumb.json +1 -1
- package/data/button-group.json +49 -0
- package/data/button.json +11 -4
- package/data/carousel-arrow.json +5 -10
- package/data/carousel-number-group.json +1 -11
- package/data/combo-box.json +5 -4
- package/data/data-grid.json +212 -0
- package/data/date-picker.json +7 -1
- package/data/dot.json +1 -1
- package/data/dropdown.json +8 -6
- package/data/empty-state.json +7 -5
- package/data/featured-icon.json +3 -2
- package/data/file-input.json +8 -2
- package/data/horizontal-tab.json +8 -7
- package/data/image-file-input.json +8 -2
- package/data/input-base.json +9 -2
- package/data/modal.json +128 -0
- package/data/notification.json +3 -2
- package/data/number-input.json +8 -2
- package/data/password-input.json +8 -2
- package/data/progress-bar.json +1 -1
- package/data/range-date-picker-with-buttons.json +12 -4
- package/data/range-date-picker.json +12 -4
- package/data/select-box.json +5 -4
- package/data/select.json +5 -4
- package/data/spinner.json +1 -1
- package/data/switch.json +16 -3
- package/data/table.json +247 -0
- package/data/tag.json +3 -2
- package/data/toggle.json +1 -1
- package/data/tooltip.json +6 -2
- package/data/vertical-tab.json +8 -6
- package/package.json +9 -4
|
@@ -226,6 +226,22 @@ Example — Modal with full structure:
|
|
|
226
226
|
|
|
227
227
|
Always check `usageExamples` in `get_component_props` for compound component patterns. You can freely omit or reorder sub-components to customize the layout.
|
|
228
228
|
|
|
229
|
+
`get_component_props` on a compound parent (e.g. `modal`, `button-group`) also returns a `subComponents` field:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"props": { "isOpen": {...}, "size": {...}, ... },
|
|
234
|
+
"subComponents": {
|
|
235
|
+
"Modal.Header": { "props": { "title": {...}, "align": {...}, "featuredIcon": {...} } },
|
|
236
|
+
"Modal.Content": { "props": { "children": {...}, "className": {...} } },
|
|
237
|
+
"Modal.Actions": { "props": { "layout": {...}, "align": {...} } }
|
|
238
|
+
},
|
|
239
|
+
"usageExamples": { ... }
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Use `subComponents[parent.sub].props` to learn valid props (types, enums, defaults) for each sub-component without calling `get_component_props` again. This prevents recursive tool calls when composing compound components.
|
|
244
|
+
|
|
229
245
|
## Rules Schema
|
|
230
246
|
|
|
231
247
|
The `rules` array in ping response is a flat list of strings grouped by category. Categories:
|
package/bin/server.mjs
CHANGED
|
File without changes
|
|
@@ -14,6 +14,7 @@ const getComponentProps = (componentMap, name) => {
|
|
|
14
14
|
return (0, response_js_1.componentNotFoundResponse)(normalized);
|
|
15
15
|
return (0, response_js_1.successResponse)({
|
|
16
16
|
props: component.props,
|
|
17
|
+
...(component.subComponents && { subComponents: component.subComponents }),
|
|
17
18
|
...(component.usageExamples && { usageExamples: component.usageExamples }),
|
|
18
19
|
});
|
|
19
20
|
};
|
|
@@ -5,38 +5,37 @@ const response_js_1 = require("../utils/response.js");
|
|
|
5
5
|
// ── 상수 ──────────────────────────────────────────────────────────
|
|
6
6
|
/** React 특수 props 차단 — injection 방지 */
|
|
7
7
|
const BLOCKED_PROPS = new Set(['dangerouslySetInnerHTML', 'ref', '__self', '__source']);
|
|
8
|
-
/** 아이콘 관련 props — React 변환 시 import 생성 대상 */
|
|
9
|
-
const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon', 'groupIcon']);
|
|
10
8
|
/** children 중첩 렌더링 제한 */
|
|
11
9
|
const MAX_CHILDREN_DEPTH = 5;
|
|
12
10
|
const MAX_CHILDREN_COUNT = 30;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const isIconSlot = (value) => typeof value === 'object' &&
|
|
16
|
-
value !== null &&
|
|
17
|
-
value.type === 'icon' &&
|
|
18
|
-
typeof value.icon === 'string';
|
|
11
|
+
/** spec-walk 재귀 깊이 제한 — 순환 spec 방지 */
|
|
12
|
+
const MAX_SPEC_DEPTH = 6;
|
|
19
13
|
// ── 타입 가드: children 중첩 ────────────────────────────────────────
|
|
20
14
|
/** { component: string, props?: object } 형태인지 판별 */
|
|
21
15
|
const isChildDescriptor = (value) => typeof value === 'object' && value !== null && typeof value.component === 'string';
|
|
22
16
|
/** PascalCase 변환: "header" → "Header" */
|
|
23
17
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
24
|
-
/** 단순 컴포넌트 resolve: "button" →
|
|
18
|
+
/** 단순 컴포넌트 resolve: "button" → { Component, propsSpec } */
|
|
25
19
|
const resolveSimpleComponent = (name, ctx) => {
|
|
26
20
|
const normalized = (0, response_js_1.normalizeName)(name);
|
|
27
21
|
const childData = ctx.componentMap.get(normalized);
|
|
28
|
-
|
|
22
|
+
const Component = childData?.exportName ? ctx.bundle[childData.exportName] ?? null : null;
|
|
23
|
+
return { Component, propsSpec: childData?.props };
|
|
29
24
|
};
|
|
30
|
-
/** compound component resolve: "modal.header" → bundle.Modal.Header */
|
|
25
|
+
/** compound component resolve: "modal.header" → { Component: bundle.Modal.Header, propsSpec: subComponents["Modal.Header"].props } */
|
|
31
26
|
const resolveCompoundComponent = (name, dotIndex, ctx) => {
|
|
32
27
|
const parentName = name.slice(0, dotIndex).toLowerCase();
|
|
33
28
|
const subName = name.slice(dotIndex + 1);
|
|
34
29
|
const parentData = ctx.componentMap.get(parentName);
|
|
35
30
|
const parentExportName = parentData?.exportName;
|
|
36
31
|
if (!parentExportName)
|
|
37
|
-
return null;
|
|
32
|
+
return { Component: null, propsSpec: undefined };
|
|
38
33
|
const Parent = ctx.bundle[parentExportName];
|
|
39
|
-
|
|
34
|
+
const Component = Parent?.[capitalize(subName)] ?? null;
|
|
35
|
+
// subComponents key는 "Modal.Header" 형태 (parent export name + sub name)
|
|
36
|
+
const compoundKey = `${parentExportName}.${capitalize(subName)}`;
|
|
37
|
+
const propsSpec = parentData?.subComponents?.[compoundKey]?.props;
|
|
38
|
+
return { Component, propsSpec };
|
|
40
39
|
};
|
|
41
40
|
/** children JSON을 재귀적으로 React element로 변환 */
|
|
42
41
|
const resolveChildren = (children, ctx, depth) => {
|
|
@@ -53,22 +52,23 @@ const resolveChildren = (children, ctx, depth) => {
|
|
|
53
52
|
if (isChildDescriptor(children)) {
|
|
54
53
|
const componentName = children.component.trim();
|
|
55
54
|
const dotIndex = componentName.indexOf('.');
|
|
56
|
-
// dot notation: "modal.header" → bundle.Modal.Header
|
|
57
|
-
const Component = dotIndex > 0
|
|
55
|
+
// dot notation: "modal.header" → bundle.Modal.Header (+ subComponents props spec)
|
|
56
|
+
const { Component, propsSpec } = dotIndex > 0
|
|
58
57
|
? resolveCompoundComponent(componentName, dotIndex, ctx)
|
|
59
58
|
: resolveSimpleComponent(componentName, ctx);
|
|
60
59
|
if (!Component)
|
|
61
60
|
return null;
|
|
62
|
-
|
|
61
|
+
// BLOCKED_PROPS(dangerouslySetInnerHTML, ref, __self, __source)를 먼저 제거
|
|
62
|
+
// propsSpec이 있으면 화이트리스트 필터(sanitizeProps)도 적용하여 XSS/alien prop 차단
|
|
63
|
+
const rawProps = children.props ?? {};
|
|
64
|
+
const blockedRemoved = removeBlockedProps(rawProps);
|
|
65
|
+
const childProps = propsSpec ? sanitizeProps(blockedRemoved, propsSpec) : blockedRemoved;
|
|
63
66
|
// descriptor의 children 필드가 있으면 props.children보다 우선
|
|
64
67
|
const rawChildren = children.children !== undefined ? children.children : childProps.children;
|
|
65
68
|
if (rawChildren !== undefined) {
|
|
66
69
|
childProps.children = resolveChildren(rawChildren, ctx, depth + 1);
|
|
67
70
|
}
|
|
68
|
-
|
|
69
|
-
const parentName = dotIndex > 0 ? componentName.slice(0, dotIndex).toLowerCase() : componentName.toLowerCase();
|
|
70
|
-
const childData = ctx.componentMap.get(parentName);
|
|
71
|
-
const resolvedProps = resolveIconProps(childProps, ctx.iconBundle, childData?.props);
|
|
71
|
+
const resolvedProps = resolveIconProps(childProps, ctx.iconBundle, propsSpec);
|
|
72
72
|
return ctx.reactRuntime.createElement(Component, resolvedProps);
|
|
73
73
|
}
|
|
74
74
|
return children;
|
|
@@ -81,26 +81,60 @@ const sanitizeProps = (userProps, propsSpec) => {
|
|
|
81
81
|
const allowedKeys = new Set([...Object.keys(propsSpec), 'children']);
|
|
82
82
|
return Object.fromEntries(Object.entries(userProps).filter(([key]) => allowedKeys.has(key)));
|
|
83
83
|
};
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
/**
|
|
85
|
+
* spec-walk 재귀 resolver — spec.semantic === 'icon-component' 위치에서 문자열을 iconBundle 컴포넌트로 치환
|
|
86
|
+
*
|
|
87
|
+
* 이름 화이트리스트가 아닌 **타입 정체성(semantic)** 기반 판정.
|
|
88
|
+
* extract 단계에서 ts-morph가 SlotIconComponent alias를 감지하여 spec.semantic을 부착한다.
|
|
89
|
+
* 중첩 객체/배열을 spec.properties를 따라 자동 순회하므로 어떤 깊이에도 대응.
|
|
90
|
+
*/
|
|
91
|
+
const resolveValueBySpec = (value, spec, iconBundle, depth = 0) => {
|
|
92
|
+
if (depth > MAX_SPEC_DEPTH)
|
|
93
|
+
return value;
|
|
94
|
+
if (!spec)
|
|
95
|
+
return value;
|
|
96
|
+
// leaf: icon-component 위치에서 string → 컴포넌트 치환. bundle miss 시 원본 유지.
|
|
97
|
+
if (spec.semantic === 'icon-component' && typeof value === 'string') {
|
|
98
|
+
const iconComponent = iconBundle[value];
|
|
99
|
+
return typeof iconComponent === 'function' ? iconComponent : value;
|
|
100
|
+
}
|
|
101
|
+
// 배열: spec이 배열 타입이고 value가 배열이면 각 요소에 재귀 (spec.properties는 요소 shape)
|
|
102
|
+
if (spec.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
|
|
103
|
+
return value.map((item) => {
|
|
104
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
105
|
+
const obj = item;
|
|
106
|
+
const resolved = { ...obj };
|
|
107
|
+
for (const [key, childSpec] of Object.entries(spec.properties)) {
|
|
108
|
+
if (key in resolved) {
|
|
109
|
+
resolved[key] = resolveValueBySpec(resolved[key], childSpec, iconBundle, depth + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
return item;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// 중첩 객체: spec.properties를 따라 각 키에 재귀 적용
|
|
118
|
+
if (spec.properties && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
119
|
+
const obj = value;
|
|
120
|
+
const resolved = { ...obj };
|
|
121
|
+
for (const [key, childSpec] of Object.entries(spec.properties)) {
|
|
122
|
+
if (key in resolved) {
|
|
123
|
+
resolved[key] = resolveValueBySpec(resolved[key], childSpec, iconBundle, depth + 1);
|
|
95
124
|
}
|
|
96
|
-
continue;
|
|
97
125
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
};
|
|
130
|
+
/** 최상위 props를 propsSpec에 따라 재귀 resolve */
|
|
131
|
+
const resolveIconProps = (props, iconBundle, propsSpec) => {
|
|
132
|
+
if (!propsSpec)
|
|
133
|
+
return props;
|
|
134
|
+
const resolved = { ...props };
|
|
135
|
+
for (const [key, spec] of Object.entries(propsSpec)) {
|
|
136
|
+
if (key in resolved) {
|
|
137
|
+
resolved[key] = resolveValueBySpec(resolved[key], spec, iconBundle);
|
|
104
138
|
}
|
|
105
139
|
}
|
|
106
140
|
return resolved;
|
|
@@ -108,19 +142,64 @@ const resolveIconProps = (props, iconBundle, propsSpec) => {
|
|
|
108
142
|
// ── JSX 변환 ──────────────────────────────────────────────────────
|
|
109
143
|
/** JSX attribute 값에서 " 를 이스케이프 */
|
|
110
144
|
const escapeJsxAttrValue = (value) => value.replace(/"/g, '"');
|
|
111
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* spec-walk 재귀로 JSX 값 문자열을 생성.
|
|
147
|
+
* icon-component 위치의 문자열은 컴포넌트 이름 참조(중괄호 없는 identifier)로,
|
|
148
|
+
* 중첩 객체는 `{ key: ... }` object literal 형태로 직렬화.
|
|
149
|
+
*/
|
|
150
|
+
const toJsxValueBySpec = (value, spec, depth = 0) => {
|
|
151
|
+
if (depth > MAX_SPEC_DEPTH)
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
// leaf: icon-component 위치의 문자열은 identifier로 반환
|
|
154
|
+
if (spec?.semantic === 'icon-component' && typeof value === 'string')
|
|
155
|
+
return value;
|
|
156
|
+
// 배열: 각 요소를 재귀 직렬화
|
|
157
|
+
if (spec?.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
|
|
158
|
+
const items = value.map((item) => {
|
|
159
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
160
|
+
const obj = item;
|
|
161
|
+
const entries = Object.entries(obj).map(([k, v]) => {
|
|
162
|
+
const childSpec = spec.properties?.[k];
|
|
163
|
+
return `${k}: ${toJsxValueBySpec(v, childSpec, depth + 1)}`;
|
|
164
|
+
});
|
|
165
|
+
return `{ ${entries.join(', ')} }`;
|
|
166
|
+
}
|
|
167
|
+
return JSON.stringify(item);
|
|
168
|
+
});
|
|
169
|
+
return `[${items.join(', ')}]`;
|
|
170
|
+
}
|
|
171
|
+
// 중첩 객체: spec.properties 따라 각 키를 재귀 직렬화
|
|
172
|
+
if (spec?.properties && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
173
|
+
const obj = value;
|
|
174
|
+
const entries = Object.entries(obj).map(([k, v]) => {
|
|
175
|
+
const childSpec = spec.properties?.[k];
|
|
176
|
+
return `${k}: ${toJsxValueBySpec(v, childSpec, depth + 1)}`;
|
|
177
|
+
});
|
|
178
|
+
return `{ ${entries.join(', ')} }`;
|
|
179
|
+
}
|
|
180
|
+
if (typeof value === 'string')
|
|
181
|
+
return JSON.stringify(value);
|
|
182
|
+
return JSON.stringify(value);
|
|
183
|
+
};
|
|
184
|
+
/** 단일 prop을 JSX attribute 문자열로 변환 — spec-driven */
|
|
112
185
|
const toJsxAttr = (key, value, spec) => {
|
|
113
186
|
if (typeof value === 'boolean' && value)
|
|
114
187
|
return key;
|
|
115
|
-
//
|
|
116
|
-
if (
|
|
188
|
+
// icon-component 최상위 prop — key={SearchLg} 형태
|
|
189
|
+
if (spec?.semantic === 'icon-component' && typeof value === 'string')
|
|
117
190
|
return `${key}={${value}}`;
|
|
191
|
+
// 배열 prop — key={[{ ... }, { ... }]} 형태
|
|
192
|
+
if (spec?.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
|
|
193
|
+
return `${key}={${toJsxValueBySpec(value, spec)}}`;
|
|
194
|
+
}
|
|
195
|
+
// 중첩 spec 객체 — key={{ icon: X, color: 'success', ... }} 형태 (JSX 표현식 중괄호로 감싸기)
|
|
196
|
+
if (spec?.properties && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
197
|
+
return `${key}={${toJsxValueBySpec(value, spec)}}`;
|
|
198
|
+
}
|
|
118
199
|
if (typeof value === 'string')
|
|
119
200
|
return `${key}="${escapeJsxAttrValue(value)}"`;
|
|
120
201
|
if (typeof value === 'number')
|
|
121
202
|
return `${key}={${value}}`;
|
|
122
|
-
if (ICON_PROP_NAMES.has(key) && isIconSlot(value))
|
|
123
|
-
return `${key}={{ type: 'icon', icon: ${value.icon} }}`;
|
|
124
203
|
if (spec?.type === 'enum' || spec?.type === 'string')
|
|
125
204
|
return `${key}="${String(value)}"`;
|
|
126
205
|
return `${key}={${JSON.stringify(value)}}`;
|
|
@@ -131,19 +210,47 @@ const propsToJsxAttrs = (props, propsSpec) => Object.entries(props)
|
|
|
131
210
|
.map(([key, value]) => toJsxAttr(key, value, propsSpec[key]))
|
|
132
211
|
.join(' ');
|
|
133
212
|
// ── 아이콘 추출 ──────────────────────────────────────────────────
|
|
134
|
-
/** icon
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
return
|
|
138
|
-
if (typeof value === 'string' && value.length > 0)
|
|
139
|
-
|
|
140
|
-
|
|
213
|
+
/** spec-walk로 value에서 icon-component 위치의 문자열 이름을 수집. 중첩 객체 + 배열까지 내려감. */
|
|
214
|
+
const collectIconNamesBySpec = (value, spec, acc, depth = 0) => {
|
|
215
|
+
if (depth > MAX_SPEC_DEPTH || !spec)
|
|
216
|
+
return;
|
|
217
|
+
if (spec.semantic === 'icon-component' && typeof value === 'string' && value.length > 0) {
|
|
218
|
+
acc.add(value);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// 배열 요소 재귀
|
|
222
|
+
if (spec.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
|
|
223
|
+
for (const item of value) {
|
|
224
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
225
|
+
const obj = item;
|
|
226
|
+
for (const [key, childSpec] of Object.entries(spec.properties)) {
|
|
227
|
+
if (key in obj)
|
|
228
|
+
collectIconNamesBySpec(obj[key], childSpec, acc, depth + 1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (spec.properties && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
235
|
+
const obj = value;
|
|
236
|
+
for (const [key, childSpec] of Object.entries(spec.properties)) {
|
|
237
|
+
if (key in obj) {
|
|
238
|
+
collectIconNamesBySpec(obj[key], childSpec, acc, depth + 1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
/** propsSpec을 따라 appliedProps에서 아이콘 이름을 수집 — 이름 화이트리스트 없음 */
|
|
244
|
+
const extractIconNames = (props, propsSpec) => {
|
|
245
|
+
if (!propsSpec)
|
|
246
|
+
return [];
|
|
247
|
+
const acc = new Set();
|
|
248
|
+
for (const [key, spec] of Object.entries(propsSpec)) {
|
|
249
|
+
if (key in props)
|
|
250
|
+
collectIconNamesBySpec(props[key], spec, acc);
|
|
251
|
+
}
|
|
252
|
+
return [...acc];
|
|
141
253
|
};
|
|
142
|
-
/** appliedProps에서 아이콘 이름을 추출 (PascalCase) */
|
|
143
|
-
const extractIconNames = (props) => Object.entries(props)
|
|
144
|
-
.filter(([key]) => ICON_PROP_NAMES.has(key))
|
|
145
|
-
.map(([, value]) => extractIconName(value))
|
|
146
|
-
.filter((name) => name !== null);
|
|
147
254
|
// ── 검증 ────────────────────────────────────────────────────────
|
|
148
255
|
/** 잘못된 enum 값을 경고 문자열로 변환 — 교정 결과도 포함 */
|
|
149
256
|
const formatInvalidEnum = (key, value, spec) => {
|
|
@@ -202,8 +309,8 @@ const correctMissingRequired = (userProps, propsSpec) => {
|
|
|
202
309
|
corrected[key] = spec.default;
|
|
203
310
|
continue;
|
|
204
311
|
}
|
|
205
|
-
// icon
|
|
206
|
-
if (spec.
|
|
312
|
+
// icon-component 타입 — 빈 span 반환 스텁 주입하여 crash 방지 (spec.semantic 기반 판정)
|
|
313
|
+
if (spec.semantic === 'icon-component') {
|
|
207
314
|
corrected[key] = () => null;
|
|
208
315
|
continue;
|
|
209
316
|
}
|
|
@@ -229,24 +336,105 @@ const correctMissingRequired = (userProps, propsSpec) => {
|
|
|
229
336
|
const calcDefaultsUsed = (propsSpec, userProps) => Object.fromEntries(Object.entries(propsSpec)
|
|
230
337
|
.filter(([key, spec]) => spec.default !== undefined && !(key in userProps))
|
|
231
338
|
.map(([key, spec]) => [key, spec.default]));
|
|
232
|
-
/**
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
339
|
+
/** descriptor의 component 이름에서 해당 컴포넌트의 propsSpec을 조회 */
|
|
340
|
+
const lookupPropsSpecByComponentName = (componentName, componentMap) => {
|
|
341
|
+
const dotIndex = componentName.indexOf('.');
|
|
342
|
+
if (dotIndex > 0) {
|
|
343
|
+
const parentName = componentName.slice(0, dotIndex).toLowerCase();
|
|
344
|
+
const subName = componentName.slice(dotIndex + 1);
|
|
345
|
+
const parentData = componentMap.get(parentName);
|
|
346
|
+
if (!parentData?.exportName)
|
|
347
|
+
return undefined;
|
|
348
|
+
const compoundKey = `${parentData.exportName}.${capitalize(subName)}`;
|
|
349
|
+
return parentData.subComponents?.[compoundKey]?.props;
|
|
350
|
+
}
|
|
351
|
+
return componentMap.get((0, response_js_1.normalizeName)(componentName))?.props;
|
|
352
|
+
};
|
|
353
|
+
/** descriptor/React element 트리를 JSX 문자열로 변환. spec을 따라 icon-component를 식별자로 직렬화하고 사용된 icon 이름을 누적 */
|
|
354
|
+
const reactElementToJsx = (node, ctx) => {
|
|
355
|
+
if (node == null)
|
|
356
|
+
return '';
|
|
357
|
+
if (typeof node === 'string')
|
|
358
|
+
return node;
|
|
359
|
+
if (typeof node === 'number' || typeof node === 'boolean')
|
|
360
|
+
return String(node);
|
|
361
|
+
if (Array.isArray(node))
|
|
362
|
+
return node.map((n) => reactElementToJsx(n, ctx)).join('\n');
|
|
363
|
+
// Component descriptor: { component, props, children } — compound component 포함
|
|
364
|
+
if (typeof node === 'object' && 'component' in node) {
|
|
365
|
+
const desc = node;
|
|
366
|
+
const componentName = desc.component.trim();
|
|
367
|
+
const pascalName = componentName.split('.').map(capitalize).join('.');
|
|
368
|
+
const propsSpec = lookupPropsSpecByComponentName(componentName, ctx.componentMap);
|
|
369
|
+
// 이 descriptor에서 사용된 icon 이름 수집 (imports 생성용)
|
|
370
|
+
if (propsSpec && desc.props) {
|
|
371
|
+
for (const [key, spec] of Object.entries(propsSpec)) {
|
|
372
|
+
if (key in desc.props)
|
|
373
|
+
collectIconNamesBySpec(desc.props[key], spec, ctx.iconNames);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const { children: descChildren, ...restProps } = desc.props || {};
|
|
377
|
+
const attrs = Object.entries(restProps)
|
|
378
|
+
.filter(([, v]) => v !== undefined)
|
|
379
|
+
.map(([k, v]) => toJsxAttr(k, v, propsSpec?.[k]))
|
|
380
|
+
.join(' ');
|
|
381
|
+
const attrsStr = attrs ? ` ${attrs}` : '';
|
|
382
|
+
const inner = desc.children ?? descChildren;
|
|
383
|
+
if (inner != null) {
|
|
384
|
+
return `<${pascalName}${attrsStr}>${reactElementToJsx(inner, ctx)}</${pascalName}>`;
|
|
242
385
|
}
|
|
386
|
+
return `<${pascalName}${attrsStr} />`;
|
|
243
387
|
}
|
|
388
|
+
// React element: { type, props, ... } — spec 조회 불가 → 최소 직렬화
|
|
389
|
+
if (typeof node === 'object' && 'type' in node) {
|
|
390
|
+
const el = node;
|
|
391
|
+
const name = typeof el.type === 'string'
|
|
392
|
+
? el.type
|
|
393
|
+
: typeof el.type === 'function'
|
|
394
|
+
? el.type.displayName
|
|
395
|
+
|| el.type.name
|
|
396
|
+
|| 'Component'
|
|
397
|
+
: 'Component';
|
|
398
|
+
const { children: childChildren, ...restProps } = el.props || {};
|
|
399
|
+
const attrs = Object.entries(restProps)
|
|
400
|
+
.filter(([, v]) => v !== undefined)
|
|
401
|
+
.map(([k, v]) => {
|
|
402
|
+
if (typeof v === 'boolean' && v)
|
|
403
|
+
return k;
|
|
404
|
+
if (typeof v === 'string')
|
|
405
|
+
return `${k}="${v}"`;
|
|
406
|
+
return `${k}={${JSON.stringify(v)}}`;
|
|
407
|
+
})
|
|
408
|
+
.join(' ');
|
|
409
|
+
const attrsStr = attrs ? ` ${attrs}` : '';
|
|
410
|
+
if (childChildren != null) {
|
|
411
|
+
return `<${name}${attrsStr}>${reactElementToJsx(childChildren, ctx)}</${name}>`;
|
|
412
|
+
}
|
|
413
|
+
return `<${name}${attrsStr} />`;
|
|
414
|
+
}
|
|
415
|
+
return String(node);
|
|
416
|
+
};
|
|
417
|
+
/** React 변환 매핑 정보를 생성 — top-level + 중첩 descriptor에서 사용된 icon을 모두 수집 */
|
|
418
|
+
const buildReactOutput = (componentData, appliedProps, iconMeta, componentMap) => {
|
|
419
|
+
const { exportName, importPath, props: propsSpec } = componentData;
|
|
420
|
+
const dependencies = [importPath];
|
|
421
|
+
// top-level props에서 icon 이름 수집
|
|
422
|
+
const iconNames = new Set(extractIconNames(appliedProps, propsSpec));
|
|
244
423
|
const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
|
|
245
424
|
const attrsPrefix = attrsStr ? ' ' + attrsStr : '';
|
|
246
425
|
const children = appliedProps.children;
|
|
426
|
+
// children 트리 직렬화 — 그 과정에서 중첩 descriptor의 icon도 누적
|
|
427
|
+
const jsxCtx = { componentMap, iconNames };
|
|
247
428
|
const jsx = children
|
|
248
|
-
? `<${exportName}${attrsPrefix}>${
|
|
429
|
+
? `<${exportName}${attrsPrefix}>${reactElementToJsx(children, jsxCtx)}</${exportName}>`
|
|
249
430
|
: `<${exportName}${attrsPrefix} />`;
|
|
431
|
+
const imports = [`import { ${exportName} } from '${importPath}';`];
|
|
432
|
+
if (iconNames.size > 0 && iconMeta) {
|
|
433
|
+
imports.push(`import { ${[...iconNames].join(', ')} } from '${iconMeta.packageName}';`);
|
|
434
|
+
if (!dependencies.includes(iconMeta.packageName)) {
|
|
435
|
+
dependencies.push(iconMeta.packageName);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
250
438
|
return { imports, jsx, cssImport: `import '${importPath}/style.css';`, dependencies };
|
|
251
439
|
};
|
|
252
440
|
/** dataVersion 객체를 생성 */
|
|
@@ -283,11 +471,11 @@ const renderToHtml = (params) => {
|
|
|
283
471
|
return (0, response_js_1.componentNotFoundResponse)(normalized);
|
|
284
472
|
const exportName = componentData.exportName;
|
|
285
473
|
if (!exportName) {
|
|
286
|
-
return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (
|
|
474
|
+
return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (pnpm extract).');
|
|
287
475
|
}
|
|
288
476
|
const Component = bundle[exportName] ?? null;
|
|
289
477
|
if (!Component) {
|
|
290
|
-
return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (
|
|
478
|
+
return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (pnpm build:bundle).');
|
|
291
479
|
}
|
|
292
480
|
try {
|
|
293
481
|
const safeProps = removeBlockedProps(props ?? {});
|
|
@@ -317,11 +505,10 @@ const renderToHtml = (params) => {
|
|
|
317
505
|
js: buildJsField(componentData, jsApiMap),
|
|
318
506
|
cdn: cdnMeta ?? undefined,
|
|
319
507
|
dataVersion: buildDataVersion(cdnMeta, iconMeta),
|
|
320
|
-
react: buildReactOutput(componentData, corrected, iconMeta),
|
|
508
|
+
react: buildReactOutput(componentData, corrected, iconMeta, componentMap),
|
|
321
509
|
});
|
|
322
510
|
}
|
|
323
511
|
catch (err) {
|
|
324
|
-
// catch에서 warnings 재계산 (let 금지 규칙 준수 — validateProps는 순수 함수이므로 동일 결과 보장)
|
|
325
512
|
const catchWarnings = componentData.props ? validateProps(props ?? {}, componentData.props) : [];
|
|
326
513
|
const suggestion = componentData.usageExamples
|
|
327
514
|
? 'get_component_props로 usageExamples를 확인하고 해당 props로 재시도하세요.'
|
package/bin/types.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface PropSpec {
|
|
|
8
8
|
values?: string[];
|
|
9
9
|
rawType?: string;
|
|
10
10
|
properties?: Record<string, PropSpec>;
|
|
11
|
+
/** 타입의 의미적 정체성 — 예: 'icon-component'는 SlotIconComponent 계열 아이콘 컴포넌트. renderer가 iconBundle resolve 대상 판정에 사용 */
|
|
12
|
+
semantic?: 'icon-component';
|
|
11
13
|
}
|
|
12
14
|
export interface ComponentUsage {
|
|
13
15
|
import: string;
|
|
@@ -30,6 +32,11 @@ export interface ComponentData {
|
|
|
30
32
|
/** render_to_html에 전달할 실제 사용 예시 props — meta.ts에서 추출 */
|
|
31
33
|
usageExamples?: Record<string, Record<string, unknown>>;
|
|
32
34
|
props: Record<string, PropSpec>;
|
|
35
|
+
/** Compound 컴포넌트의 서브 컴포넌트 props 스펙 — 예: Modal.Header, Table.Row
|
|
36
|
+
* 키는 "Modal.Header" 형태의 dot notation. 서브가 없으면 undefined. */
|
|
37
|
+
subComponents?: Record<string, {
|
|
38
|
+
props: Record<string, PropSpec>;
|
|
39
|
+
}>;
|
|
33
40
|
html?: Record<string, string>;
|
|
34
41
|
bemClasses: string[];
|
|
35
42
|
usage: ComponentUsage;
|
|
@@ -21,7 +21,7 @@ export declare const getClassList: (el: {
|
|
|
21
21
|
export declare const buildRootClassMap: (componentMap: Map<string, ComponentData>) => Map<string, string>;
|
|
22
22
|
/** 엘리먼트별 BEM 클래스 검증 → errors + warnings + invalidClasses 수집 */
|
|
23
23
|
export declare const collectBemErrors: (params: {
|
|
24
|
-
elements: ReturnType<ReturnType<typeof parse>[
|
|
24
|
+
elements: ReturnType<ReturnType<typeof parse>["querySelectorAll"]>;
|
|
25
25
|
rootClassMap: Map<string, string>;
|
|
26
26
|
componentMap: Map<string, ComponentData>;
|
|
27
27
|
}) => {
|
package/bin/utils/compliance.js
CHANGED
|
@@ -183,7 +183,9 @@ const buildComplianceSummary = (params) => {
|
|
|
183
183
|
const isMedium = err.type === 'ncua_not_used' && err.severity === 'warning';
|
|
184
184
|
weightedViolations += isMedium ? NCUA_NOT_USED_MEDIUM_WEIGHT : COMPLIANCE_WEIGHTS[err.type];
|
|
185
185
|
}
|
|
186
|
-
const
|
|
186
|
+
const ncuaNotUsedCount = ncuaErrors.length;
|
|
187
|
+
const totalCheckpoints = ncuaUsage.used +
|
|
188
|
+
ncuaNotUsedCount +
|
|
187
189
|
(tokenUsage.correct + tokenUsage.missing + tokenUsage.invalid) +
|
|
188
190
|
(customSeparation.clean + customSeparation.violated);
|
|
189
191
|
const score = totalCheckpoints === 0 ? 1.0 : Math.max(0, Math.min(1, 1 - weightedViolations / totalCheckpoints));
|
|
@@ -8,7 +8,6 @@ export interface CdnMeta {
|
|
|
8
8
|
export interface IconMeta {
|
|
9
9
|
packageName: string;
|
|
10
10
|
version: string;
|
|
11
|
-
cdn: string;
|
|
12
11
|
}
|
|
13
12
|
/** data/ 디렉토리의 JSON 파일을 읽어 componentMap으로 반환 */
|
|
14
13
|
export declare const loadComponentsFromDir: (dataDir: string) => Map<string, ComponentData>;
|
package/bin/utils/dataLoader.js
CHANGED
|
@@ -27,30 +27,22 @@ const isValidIconJson = (data) => !!data && typeof data === 'object' && 'icons'
|
|
|
27
27
|
/** data/ 디렉토리의 JSON 파일을 읽어 componentMap으로 반환 */
|
|
28
28
|
const loadComponentsFromDir = (dataDir) => {
|
|
29
29
|
if (!fs_1.default.existsSync(dataDir)) {
|
|
30
|
-
|
|
31
|
-
process.exit(1);
|
|
30
|
+
throw new Error(`mcp/data/ 디렉토리가 없습니다: ${dataDir}`);
|
|
32
31
|
}
|
|
33
32
|
const jsonFiles = fs_1.default.readdirSync(dataDir).filter((f) => f.endsWith('.json') && !f.startsWith('_'));
|
|
34
33
|
if (jsonFiles.length === 0) {
|
|
35
|
-
|
|
36
|
-
process.exit(1);
|
|
34
|
+
throw new Error(`mcp/data/ 디렉토리에 JSON 파일이 없습니다: ${dataDir}`);
|
|
37
35
|
}
|
|
38
36
|
const map = new Map();
|
|
39
37
|
for (const file of jsonFiles) {
|
|
40
38
|
const filePath = path_1.default.join(dataDir, file);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
39
|
+
const raw = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (!isValidComponentJson(parsed)) {
|
|
42
|
+
throw new Error(`JSON 파싱 실패 (${file}): name 필드가 없거나 올바르지 않습니다`);
|
|
53
43
|
}
|
|
44
|
+
const component = parsed;
|
|
45
|
+
map.set(component.name, component);
|
|
54
46
|
}
|
|
55
47
|
logger_js_1.logger.info(`컴포넌트 ${map.size}개 로딩 완료`);
|
|
56
48
|
return map;
|
|
@@ -46,6 +46,7 @@ const setupDomEnvironment = () => {
|
|
|
46
46
|
globalThis.document = dom.window.document;
|
|
47
47
|
if (typeof globalThis.window === 'undefined')
|
|
48
48
|
globalThis.window = dom.window;
|
|
49
|
+
/* v8 ignore next 3 -- Node.js 환경에서 navigator 미존재 시 설정. 테스트 실행 순서에 따라 이미 설정된 경우 스킵 */
|
|
49
50
|
if (typeof globalThis.navigator === 'undefined') {
|
|
50
51
|
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, writable: true });
|
|
51
52
|
}
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* - 부분 포함 ("비번" ⊂ "비밀번호"는 안 되지만, "로딩" ⊂ "로딩중"은 됨)
|
|
6
6
|
* - 편집 거리(Levenshtein) 기반 오타 허용 ("셀랙트" ↔ "셀렉트", "buttn" ↔ "button")
|
|
7
7
|
*/
|
|
8
|
+
/** Levenshtein 편집 거리 */
|
|
9
|
+
export declare const editDistance: (a: string, b: string) => number;
|
|
10
|
+
/** 편집 거리 기반 유사도 (0~1, 1이 완전 일치) */
|
|
11
|
+
export declare const similarity: (a: string, b: string) => number;
|
|
8
12
|
export interface FuzzyResult {
|
|
9
13
|
score: number;
|
|
10
14
|
matchType: 'exact' | 'contains' | 'fuzzy';
|
package/bin/utils/fuzzyMatch.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - 편집 거리(Levenshtein) 기반 오타 허용 ("셀랙트" ↔ "셀렉트", "buttn" ↔ "button")
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.bestFuzzyMatch = exports.fuzzyMatch = void 0;
|
|
10
|
+
exports.bestFuzzyMatch = exports.fuzzyMatch = exports.similarity = exports.editDistance = void 0;
|
|
11
11
|
// ── 상수 ──────────────────────────────────────────────────────────
|
|
12
12
|
/** 포함 매칭 시 기본 점수 */
|
|
13
13
|
const CONTAINS_BASE_SCORE = 0.7;
|
|
@@ -49,6 +49,15 @@ const editDistance = (a, b) => {
|
|
|
49
49
|
}
|
|
50
50
|
return prev[n];
|
|
51
51
|
};
|
|
52
|
+
exports.editDistance = editDistance;
|
|
53
|
+
/** 편집 거리 기반 유사도 (0~1, 1이 완전 일치) */
|
|
54
|
+
const similarity = (a, b) => {
|
|
55
|
+
const maxLen = Math.max(a.length, b.length);
|
|
56
|
+
if (maxLen === 0)
|
|
57
|
+
return 1;
|
|
58
|
+
return 1 - (0, exports.editDistance)(a, b) / maxLen;
|
|
59
|
+
};
|
|
60
|
+
exports.similarity = similarity;
|
|
52
61
|
/**
|
|
53
62
|
* query가 target에 매칭되는지 퍼지 검사
|
|
54
63
|
*
|
|
@@ -71,7 +80,7 @@ const fuzzyMatch = (query, target) => {
|
|
|
71
80
|
// 3. 편집 거리 기반 퍼지 매칭
|
|
72
81
|
const isShortQuery = q.length <= SHORT_QUERY_LENGTH;
|
|
73
82
|
const typoThreshold = isShortQuery ? 1 : Math.ceil(q.length * TYPO_THRESHOLD_RATIO);
|
|
74
|
-
const dist = editDistance(q, t);
|
|
83
|
+
const dist = (0, exports.editDistance)(q, t);
|
|
75
84
|
// target이 query보다 훨씬 길면, 부분 문자열 슬라이딩 윈도우로 비교
|
|
76
85
|
const targetIsLonger = t.length > q.length + LENGTH_DIFF_FOR_SUBSTRING;
|
|
77
86
|
const queryIsLongEnough = q.length >= SHORT_QUERY_LENGTH;
|
|
@@ -80,7 +89,7 @@ const fuzzyMatch = (query, target) => {
|
|
|
80
89
|
if (shouldTrySubstringMatch) {
|
|
81
90
|
for (let i = 0; i <= t.length - q.length; i++) {
|
|
82
91
|
const sub = t.substring(i, i + q.length);
|
|
83
|
-
const subDist = editDistance(q, sub);
|
|
92
|
+
const subDist = (0, exports.editDistance)(q, sub);
|
|
84
93
|
if (subDist <= typoThreshold) {
|
|
85
94
|
const subSimilarity = q.length === 0 ? 1 : 1 - subDist / q.length;
|
|
86
95
|
return { score: SUBSTRING_BASE_SCORE + SUBSTRING_BONUS_MULTIPLIER * subSimilarity, matchType: 'fuzzy' };
|