@ncds/ui-admin-mcp 1.0.0-alpha.10 → 1.0.0-alpha.12
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 +119 -3
- package/bin/server.mjs +0 -0
- package/bin/tools/getComponentProps.js +1 -0
- package/bin/tools/renderToHtml.js +312 -60
- 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 +206 -4
- 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 +13 -24
- 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
|
@@ -54,11 +54,30 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
|
|
|
54
54
|
2. Retry render_to_html with meaningful prop values
|
|
55
55
|
3. If still empty after 3 attempts, report the issue to the user
|
|
56
56
|
|
|
57
|
-
### Step 4: CDN
|
|
57
|
+
### Step 4: CDN & JS Initialization
|
|
58
58
|
|
|
59
59
|
- Get CSS/JS URLs from the **cdn** field in the render_to_html response
|
|
60
|
-
- Include them in
|
|
61
|
-
- When **js.required is true
|
|
60
|
+
- Include them in `<head>`/`<body>` of the final HTML
|
|
61
|
+
- When **js.required is true**:
|
|
62
|
+
1. Include the CDN JS `<script>` tag — this is mandatory
|
|
63
|
+
2. Check the **js.api** field — it contains the initialization code pattern
|
|
64
|
+
3. Write a `<script>` block that initializes the component using `js.api.constructor` and `js.api.constructorParams`
|
|
65
|
+
4. Use `js.api.example` as a reference for the initialization code
|
|
66
|
+
5. Common pattern: `new window.ncua.ClassName(element, options)` — called after DOMContentLoaded
|
|
67
|
+
|
|
68
|
+
Example: DatePicker with JS initialization:
|
|
69
|
+
|
|
70
|
+
```html
|
|
71
|
+
<script>
|
|
72
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
73
|
+
// js.api.constructor: new window.ncua.DatePicker(wrapper, options)
|
|
74
|
+
// js.api.constructorParams shows required params
|
|
75
|
+
new window.ncua.DatePicker(document.getElementById('my-datepicker'), {
|
|
76
|
+
datePickerOptions: [{ element: 'start-date', options: { dateFormat: 'Y-m-d' } }],
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
</script>
|
|
80
|
+
```
|
|
62
81
|
|
|
63
82
|
### Step 5: Composition
|
|
64
83
|
|
|
@@ -124,8 +143,105 @@ If you call `render_to_html` with missing required props:
|
|
|
124
143
|
- File upload → **file-input**
|
|
125
144
|
- Image upload → **image-file-input**
|
|
126
145
|
- Plain text → **input**
|
|
146
|
+
- Dropdown/Select → **select-box** (default choice for all dropdown selections). Use **select** only when native HTML select is explicitly required (e.g., react-hook-form direct binding)
|
|
147
|
+
- Searchable dropdown → **combo-box**
|
|
148
|
+
- Custom trigger dropdown → **dropdown**
|
|
149
|
+
- Date single → **date-picker**
|
|
150
|
+
- Date range → **range-date-picker**
|
|
151
|
+
- Date range with period buttons → **range-date-picker-with-buttons**
|
|
127
152
|
- Each component's description includes usage guidance and alternatives.
|
|
128
153
|
|
|
154
|
+
### Icon Usage
|
|
155
|
+
|
|
156
|
+
Icons are rendered as inline SVG by the MCP server — NOT via CDN JS or custom tags. There is NO `<ncua-icon>` tag.
|
|
157
|
+
|
|
158
|
+
- Use `search_icon` to find icon names (PascalCase, e.g., "RefreshCw01", "SearchLg", "ChevronDown")
|
|
159
|
+
- Pass icon names to component props via `render_to_html`:
|
|
160
|
+
- Button leadingIcon/trailingIcon: `{ "type": "icon", "icon": "RefreshCw01" }`
|
|
161
|
+
- FeaturedIcon icon: `"CheckCircle"` (bare string)
|
|
162
|
+
- The server resolves icon names to actual SVG at render time
|
|
163
|
+
- NEVER write `<ncua-icon>`, `<svg>`, or emoji. If you need a standalone icon, use **featured-icon** component
|
|
164
|
+
|
|
165
|
+
Example — Button with icon:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"name": "button",
|
|
170
|
+
"props": {
|
|
171
|
+
"label": "Refresh",
|
|
172
|
+
"leadingIcon": { "type": "icon", "icon": "RefreshCw01" }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Children Composition
|
|
178
|
+
|
|
179
|
+
When a component needs child components (e.g., ButtonGroup with Buttons, Modal with form elements), pass children as component descriptors:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"name": "button-group",
|
|
184
|
+
"props": {
|
|
185
|
+
"children": [
|
|
186
|
+
{ "component": "button", "props": { "label": "Save", "hierarchy": "primary" } },
|
|
187
|
+
{ "component": "button", "props": { "label": "Cancel" } }
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This works recursively — children can contain their own children up to 5 levels deep.
|
|
194
|
+
|
|
195
|
+
### Compound Components (dot notation)
|
|
196
|
+
|
|
197
|
+
Some components have sub-components accessed via dot notation. Use `"parent.sub"` format:
|
|
198
|
+
|
|
199
|
+
- `modal.header` — Modal header with title and close button
|
|
200
|
+
- `modal.content` — Modal body content area
|
|
201
|
+
- `modal.actions` — Modal footer with action buttons
|
|
202
|
+
|
|
203
|
+
Example — Modal with full structure:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"name": "modal",
|
|
208
|
+
"props": { "isOpen": true, "size": "md" },
|
|
209
|
+
"children": [
|
|
210
|
+
{ "component": "modal.header", "props": { "title": "Product Detail", "align": "left" } },
|
|
211
|
+
{
|
|
212
|
+
"component": "modal.content",
|
|
213
|
+
"children": [{ "component": "input-base", "props": { "placeholder": "Product name" } }]
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"component": "modal.actions",
|
|
217
|
+
"props": { "layout": "horizontal", "align": "stretch" },
|
|
218
|
+
"children": [
|
|
219
|
+
{ "component": "button", "props": { "label": "Cancel", "hierarchy": "secondary-gray", "size": "sm" } },
|
|
220
|
+
{ "component": "button", "props": { "label": "Confirm", "hierarchy": "primary", "size": "sm" } }
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
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
|
+
|
|
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
|
+
|
|
129
245
|
## Rules Schema
|
|
130
246
|
|
|
131
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,14 +5,74 @@ 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
|
-
/**
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** children 중첩 렌더링 제한 */
|
|
9
|
+
const MAX_CHILDREN_DEPTH = 5;
|
|
10
|
+
const MAX_CHILDREN_COUNT = 30;
|
|
11
|
+
/** spec-walk 재귀 깊이 제한 — 순환 spec 방지 */
|
|
12
|
+
const MAX_SPEC_DEPTH = 6;
|
|
13
|
+
// ── 타입 가드: children 중첩 ────────────────────────────────────────
|
|
14
|
+
/** { component: string, props?: object } 형태인지 판별 */
|
|
15
|
+
const isChildDescriptor = (value) => typeof value === 'object' && value !== null && typeof value.component === 'string';
|
|
16
|
+
/** PascalCase 변환: "header" → "Header" */
|
|
17
|
+
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
18
|
+
/** 단순 컴포넌트 resolve: "button" → { Component, propsSpec } */
|
|
19
|
+
const resolveSimpleComponent = (name, ctx) => {
|
|
20
|
+
const normalized = (0, response_js_1.normalizeName)(name);
|
|
21
|
+
const childData = ctx.componentMap.get(normalized);
|
|
22
|
+
const Component = childData?.exportName ? ctx.bundle[childData.exportName] ?? null : null;
|
|
23
|
+
return { Component, propsSpec: childData?.props };
|
|
24
|
+
};
|
|
25
|
+
/** compound component resolve: "modal.header" → { Component: bundle.Modal.Header, propsSpec: subComponents["Modal.Header"].props } */
|
|
26
|
+
const resolveCompoundComponent = (name, dotIndex, ctx) => {
|
|
27
|
+
const parentName = name.slice(0, dotIndex).toLowerCase();
|
|
28
|
+
const subName = name.slice(dotIndex + 1);
|
|
29
|
+
const parentData = ctx.componentMap.get(parentName);
|
|
30
|
+
const parentExportName = parentData?.exportName;
|
|
31
|
+
if (!parentExportName)
|
|
32
|
+
return { Component: null, propsSpec: undefined };
|
|
33
|
+
const Parent = ctx.bundle[parentExportName];
|
|
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 };
|
|
39
|
+
};
|
|
40
|
+
/** children JSON을 재귀적으로 React element로 변환 */
|
|
41
|
+
const resolveChildren = (children, ctx, depth) => {
|
|
42
|
+
if (depth > MAX_CHILDREN_DEPTH)
|
|
43
|
+
return null;
|
|
44
|
+
// 문자열/숫자/boolean — 그대로 반환
|
|
45
|
+
if (typeof children === 'string' || typeof children === 'number' || typeof children === 'boolean')
|
|
46
|
+
return children;
|
|
47
|
+
// 배열 — 각 요소를 재귀 처리 (MAX_CHILDREN_COUNT 제한)
|
|
48
|
+
if (Array.isArray(children)) {
|
|
49
|
+
return children.slice(0, MAX_CHILDREN_COUNT).map((child) => resolveChildren(child, ctx, depth));
|
|
50
|
+
}
|
|
51
|
+
// { component, props?, children? } — React element로 변환
|
|
52
|
+
if (isChildDescriptor(children)) {
|
|
53
|
+
const componentName = children.component.trim();
|
|
54
|
+
const dotIndex = componentName.indexOf('.');
|
|
55
|
+
// dot notation: "modal.header" → bundle.Modal.Header (+ subComponents props spec)
|
|
56
|
+
const { Component, propsSpec } = dotIndex > 0
|
|
57
|
+
? resolveCompoundComponent(componentName, dotIndex, ctx)
|
|
58
|
+
: resolveSimpleComponent(componentName, ctx);
|
|
59
|
+
if (!Component)
|
|
60
|
+
return null;
|
|
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;
|
|
66
|
+
// descriptor의 children 필드가 있으면 props.children보다 우선
|
|
67
|
+
const rawChildren = children.children !== undefined ? children.children : childProps.children;
|
|
68
|
+
if (rawChildren !== undefined) {
|
|
69
|
+
childProps.children = resolveChildren(rawChildren, ctx, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
const resolvedProps = resolveIconProps(childProps, ctx.iconBundle, propsSpec);
|
|
72
|
+
return ctx.reactRuntime.createElement(Component, resolvedProps);
|
|
73
|
+
}
|
|
74
|
+
return children;
|
|
75
|
+
};
|
|
16
76
|
// ── Props 변환 ──────────────────────────────────────────────────────
|
|
17
77
|
/** BLOCKED_PROPS를 제거 — props spec 유무와 무관하게 항상 적용 */
|
|
18
78
|
const removeBlockedProps = (props) => Object.fromEntries(Object.entries(props).filter(([key]) => !BLOCKED_PROPS.has(key)));
|
|
@@ -21,26 +81,60 @@ const sanitizeProps = (userProps, propsSpec) => {
|
|
|
21
81
|
const allowedKeys = new Set([...Object.keys(propsSpec), 'children']);
|
|
22
82
|
return Object.fromEntries(Object.entries(userProps).filter(([key]) => allowedKeys.has(key)));
|
|
23
83
|
};
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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);
|
|
35
124
|
}
|
|
36
|
-
continue;
|
|
37
125
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
44
138
|
}
|
|
45
139
|
}
|
|
46
140
|
return resolved;
|
|
@@ -48,19 +142,64 @@ const resolveIconProps = (props, iconBundle, propsSpec) => {
|
|
|
48
142
|
// ── JSX 변환 ──────────────────────────────────────────────────────
|
|
49
143
|
/** JSX attribute 값에서 " 를 이스케이프 */
|
|
50
144
|
const escapeJsxAttrValue = (value) => value.replace(/"/g, '"');
|
|
51
|
-
/**
|
|
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 */
|
|
52
185
|
const toJsxAttr = (key, value, spec) => {
|
|
53
186
|
if (typeof value === 'boolean' && value)
|
|
54
187
|
return key;
|
|
55
|
-
//
|
|
56
|
-
if (
|
|
188
|
+
// icon-component 최상위 prop — key={SearchLg} 형태
|
|
189
|
+
if (spec?.semantic === 'icon-component' && typeof value === 'string')
|
|
57
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
|
+
}
|
|
58
199
|
if (typeof value === 'string')
|
|
59
200
|
return `${key}="${escapeJsxAttrValue(value)}"`;
|
|
60
201
|
if (typeof value === 'number')
|
|
61
202
|
return `${key}={${value}}`;
|
|
62
|
-
if (ICON_PROP_NAMES.has(key) && isIconSlot(value))
|
|
63
|
-
return `${key}={{ type: 'icon', icon: ${value.icon} }}`;
|
|
64
203
|
if (spec?.type === 'enum' || spec?.type === 'string')
|
|
65
204
|
return `${key}="${String(value)}"`;
|
|
66
205
|
return `${key}={${JSON.stringify(value)}}`;
|
|
@@ -71,19 +210,47 @@ const propsToJsxAttrs = (props, propsSpec) => Object.entries(props)
|
|
|
71
210
|
.map(([key, value]) => toJsxAttr(key, value, propsSpec[key]))
|
|
72
211
|
.join(' ');
|
|
73
212
|
// ── 아이콘 추출 ──────────────────────────────────────────────────
|
|
74
|
-
/** icon
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
78
|
-
if (typeof value === 'string' && value.length > 0)
|
|
79
|
-
|
|
80
|
-
|
|
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];
|
|
81
253
|
};
|
|
82
|
-
/** appliedProps에서 아이콘 이름을 추출 (PascalCase) */
|
|
83
|
-
const extractIconNames = (props) => Object.entries(props)
|
|
84
|
-
.filter(([key]) => ICON_PROP_NAMES.has(key))
|
|
85
|
-
.map(([, value]) => extractIconName(value))
|
|
86
|
-
.filter((name) => name !== null);
|
|
87
254
|
// ── 검증 ────────────────────────────────────────────────────────
|
|
88
255
|
/** 잘못된 enum 값을 경고 문자열로 변환 — 교정 결과도 포함 */
|
|
89
256
|
const formatInvalidEnum = (key, value, spec) => {
|
|
@@ -142,8 +309,8 @@ const correctMissingRequired = (userProps, propsSpec) => {
|
|
|
142
309
|
corrected[key] = spec.default;
|
|
143
310
|
continue;
|
|
144
311
|
}
|
|
145
|
-
// icon
|
|
146
|
-
if (spec.
|
|
312
|
+
// icon-component 타입 — 빈 span 반환 스텁 주입하여 crash 방지 (spec.semantic 기반 판정)
|
|
313
|
+
if (spec.semantic === 'icon-component') {
|
|
147
314
|
corrected[key] = () => null;
|
|
148
315
|
continue;
|
|
149
316
|
}
|
|
@@ -169,24 +336,105 @@ const correctMissingRequired = (userProps, propsSpec) => {
|
|
|
169
336
|
const calcDefaultsUsed = (propsSpec, userProps) => Object.fromEntries(Object.entries(propsSpec)
|
|
170
337
|
.filter(([key, spec]) => spec.default !== undefined && !(key in userProps))
|
|
171
338
|
.map(([key, spec]) => [key, spec.default]));
|
|
172
|
-
/**
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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}>`;
|
|
182
385
|
}
|
|
386
|
+
return `<${pascalName}${attrsStr} />`;
|
|
183
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));
|
|
184
423
|
const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
|
|
185
424
|
const attrsPrefix = attrsStr ? ' ' + attrsStr : '';
|
|
186
425
|
const children = appliedProps.children;
|
|
426
|
+
// children 트리 직렬화 — 그 과정에서 중첩 descriptor의 icon도 누적
|
|
427
|
+
const jsxCtx = { componentMap, iconNames };
|
|
187
428
|
const jsx = children
|
|
188
|
-
? `<${exportName}${attrsPrefix}>${
|
|
429
|
+
? `<${exportName}${attrsPrefix}>${reactElementToJsx(children, jsxCtx)}</${exportName}>`
|
|
189
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
|
+
}
|
|
190
438
|
return { imports, jsx, cssImport: `import '${importPath}/style.css';`, dependencies };
|
|
191
439
|
};
|
|
192
440
|
/** dataVersion 객체를 생성 */
|
|
@@ -223,11 +471,11 @@ const renderToHtml = (params) => {
|
|
|
223
471
|
return (0, response_js_1.componentNotFoundResponse)(normalized);
|
|
224
472
|
const exportName = componentData.exportName;
|
|
225
473
|
if (!exportName) {
|
|
226
|
-
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).');
|
|
227
475
|
}
|
|
228
476
|
const Component = bundle[exportName] ?? null;
|
|
229
477
|
if (!Component) {
|
|
230
|
-
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).');
|
|
231
479
|
}
|
|
232
480
|
try {
|
|
233
481
|
const safeProps = removeBlockedProps(props ?? {});
|
|
@@ -238,6 +486,11 @@ const renderToHtml = (params) => {
|
|
|
238
486
|
const postWarnings = componentData.props ? validateProps(corrected, componentData.props) : [];
|
|
239
487
|
const warnings = [...enumWarnings, ...postWarnings.filter((w) => !enumWarnings.includes(w))];
|
|
240
488
|
const resolvedProps = resolveIconProps(corrected, iconBundle, componentData.props);
|
|
489
|
+
// children이 컴포넌트 descriptor면 React element로 변환
|
|
490
|
+
if (resolvedProps.children !== undefined) {
|
|
491
|
+
const ctx = { bundle, iconBundle, componentMap, reactRuntime };
|
|
492
|
+
resolvedProps.children = resolveChildren(resolvedProps.children, ctx, 0);
|
|
493
|
+
}
|
|
241
494
|
const rawHtml = reactRuntime.renderToStaticMarkup(reactRuntime.createElement(Component, resolvedProps));
|
|
242
495
|
const html = `<!-- ncua:${normalized} start -->\n${rawHtml}\n<!-- ncua:${normalized} end -->`;
|
|
243
496
|
const hasDefaults = componentData.props ? calcDefaultsUsed(componentData.props, corrected) : {};
|
|
@@ -252,11 +505,10 @@ const renderToHtml = (params) => {
|
|
|
252
505
|
js: buildJsField(componentData, jsApiMap),
|
|
253
506
|
cdn: cdnMeta ?? undefined,
|
|
254
507
|
dataVersion: buildDataVersion(cdnMeta, iconMeta),
|
|
255
|
-
react: buildReactOutput(componentData, corrected, iconMeta),
|
|
508
|
+
react: buildReactOutput(componentData, corrected, iconMeta, componentMap),
|
|
256
509
|
});
|
|
257
510
|
}
|
|
258
511
|
catch (err) {
|
|
259
|
-
// catch에서 warnings 재계산 (let 금지 규칙 준수 — validateProps는 순수 함수이므로 동일 결과 보장)
|
|
260
512
|
const catchWarnings = componentData.props ? validateProps(props ?? {}, componentData.props) : [];
|
|
261
513
|
const suggestion = componentData.usageExamples
|
|
262
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));
|