@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.
Files changed (51) hide show
  1. package/bin/components.bundle.js +1 -1
  2. package/bin/definitions/instructions.md +119 -3
  3. package/bin/server.mjs +0 -0
  4. package/bin/tools/getComponentProps.js +1 -0
  5. package/bin/tools/renderToHtml.js +312 -60
  6. package/bin/types.d.ts +7 -0
  7. package/bin/utils/bemValidator.d.ts +1 -1
  8. package/bin/utils/compliance.js +3 -1
  9. package/bin/utils/dataLoader.d.ts +0 -1
  10. package/bin/utils/dataLoader.js +8 -16
  11. package/bin/utils/domEnvironment.js +1 -0
  12. package/bin/utils/fuzzyMatch.d.ts +4 -0
  13. package/bin/utils/fuzzyMatch.js +12 -3
  14. package/bin/utils/logger.d.ts +5 -5
  15. package/bin/utils/logger.js +5 -5
  16. package/data/_meta.json +4 -5
  17. package/data/badge-group.json +9 -6
  18. package/data/badge.json +32 -16
  19. package/data/bread-crumb.json +1 -1
  20. package/data/button-group.json +49 -0
  21. package/data/button.json +11 -4
  22. package/data/carousel-arrow.json +5 -10
  23. package/data/carousel-number-group.json +1 -11
  24. package/data/combo-box.json +5 -4
  25. package/data/data-grid.json +212 -0
  26. package/data/date-picker.json +7 -1
  27. package/data/dot.json +1 -1
  28. package/data/dropdown.json +8 -6
  29. package/data/empty-state.json +7 -5
  30. package/data/featured-icon.json +3 -2
  31. package/data/file-input.json +8 -2
  32. package/data/horizontal-tab.json +8 -7
  33. package/data/image-file-input.json +8 -2
  34. package/data/input-base.json +9 -2
  35. package/data/modal.json +206 -4
  36. package/data/notification.json +3 -2
  37. package/data/number-input.json +8 -2
  38. package/data/password-input.json +8 -2
  39. package/data/progress-bar.json +1 -1
  40. package/data/range-date-picker-with-buttons.json +12 -4
  41. package/data/range-date-picker.json +12 -4
  42. package/data/select-box.json +5 -4
  43. package/data/select.json +13 -24
  44. package/data/spinner.json +1 -1
  45. package/data/switch.json +16 -3
  46. package/data/table.json +247 -0
  47. package/data/tag.json +3 -2
  48. package/data/toggle.json +1 -1
  49. package/data/tooltip.json +6 -2
  50. package/data/vertical-tab.json +8 -6
  51. 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 Inclusion
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 <head>/<body> of the final HTML
61
- - When **js.required is true**, include the CDN JS <script> tag. This is mandatory.
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
- /** 아이콘 관련 props React 변환 시 import 생성 대상 */
9
- const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon', 'groupIcon']);
10
- // ── 타입 가드 ──────────────────────────────────────────────────────
11
- /** { type: 'icon', icon: string } 형태인지 판별 */
12
- const isIconSlot = (value) => typeof value === 'object' &&
13
- value !== null &&
14
- value.type === 'icon' &&
15
- typeof value.icon === 'string';
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
- /** 아이콘 prop의 icon 문자열을 실제 React 컴포넌트로 resolve */
25
- const resolveIconProps = (props, iconBundle, propsSpec) => {
26
- const resolved = { ...props };
27
- for (const key of ICON_PROP_NAMES) {
28
- const value = resolved[key];
29
- const spec = propsSpec?.[key];
30
- // bare function prop (FeaturedIcon 등) — 문자열 이름을 컴포넌트 함수로 직접 resolve
31
- if (spec?.type === 'function' && typeof value === 'string') {
32
- const iconComponent = iconBundle[value];
33
- if (typeof iconComponent === 'function') {
34
- resolved[key] = iconComponent;
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
- // IconSlotType 래퍼 (Button leadingIcon 등) — 래퍼 내부 icon을 컴포넌트로 교체
39
- if (!isIconSlot(value))
40
- continue;
41
- const iconComponent = iconBundle[value.icon];
42
- if (typeof iconComponent === 'function') {
43
- resolved[key] = { ...value, icon: iconComponent };
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, '&quot;');
51
- /** 단일 prop을 JSX attribute 문자열로 변환 */
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
- // bare function icon prop (FeaturedIcon 등) icon={SearchLg} 형태
56
- if (ICON_PROP_NAMES.has(key) && spec?.type === 'function' && typeof value === 'string')
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 prop 값에서 아이콘 이름을 추출 */
75
- const extractIconName = (value) => {
76
- if (isIconSlot(value))
77
- return value.icon;
78
- if (typeof value === 'string' && value.length > 0)
79
- return value;
80
- return null;
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 function prop (FeaturedIcon 등) — 빈 span 반환 스텁 주입하여 crash 방지
146
- if (spec.type === 'function' && ICON_PROP_NAMES.has(key)) {
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
- /** React 변환 매핑 정보를 생성 */
173
- const buildReactOutput = (componentData, appliedProps, iconMeta) => {
174
- const { exportName, importPath, props: propsSpec } = componentData;
175
- const imports = [`import { ${exportName} } from '${importPath}';`];
176
- const dependencies = [importPath];
177
- const iconNames = extractIconNames(appliedProps);
178
- if (iconNames.length > 0 && iconMeta) {
179
- imports.push(`import { ${iconNames.join(', ')} } from '${iconMeta.packageName}';`);
180
- if (!dependencies.includes(iconMeta.packageName)) {
181
- dependencies.push(iconMeta.packageName);
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}>${String(children)}</${exportName}>`
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이 없습니다.`, '데이터를 재추출해주세요 (yarn extract).');
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}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (yarn build:bundle).');
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>['querySelectorAll']>;
24
+ elements: ReturnType<ReturnType<typeof parse>["querySelectorAll"]>;
25
25
  rootClassMap: Map<string, string>;
26
26
  componentMap: Map<string, ComponentData>;
27
27
  }) => {
@@ -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 totalCheckpoints = ncuaUsage.available +
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));