@ncds/ui-admin-mcp 1.0.0-alpha.8 → 1.0.0-alpha.9
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/definitions/instructions.md +14 -2
- package/bin/definitions/rules.json +2 -1
- package/bin/server.js +21 -14
- package/bin/tools/renderToHtml.d.ts +3 -2
- package/bin/tools/renderToHtml.js +119 -126
- package/bin/types.d.ts +9 -0
- package/bin/utils/dataLoader.d.ts +1 -10
- package/bin/utils/response.d.ts +2 -0
- package/bin/utils/response.js +7 -1
- package/data/badge-group.json +177 -3
- package/data/badge.json +118 -2
- package/data/bread-crumb.json +22 -2
- package/data/button.json +118 -2
- package/data/combo-box.json +19 -3
- package/data/date-picker.json +2 -1
- package/data/dropdown.json +93 -4
- package/data/empty-state.json +165 -2
- package/data/featured-icon.json +1 -1
- package/data/file-input.json +158 -4
- package/data/horizontal-tab.json +216 -1
- package/data/image-file-input.json +158 -4
- package/data/input-base.json +156 -2
- package/data/notification.json +21 -3
- package/data/number-input.json +156 -2
- package/data/password-input.json +220 -2
- package/data/progress-bar.json +21 -2
- package/data/range-date-picker-with-buttons.json +159 -6
- package/data/range-date-picker.json +158 -5
- package/data/select-box.json +19 -3
- package/data/select.json +19 -3
- package/data/spinner.json +1 -2
- package/data/switch.json +24 -2
- package/data/tag.json +59 -1
- package/data/vertical-tab.json +217 -2
- package/package.json +1 -1
- package/bin/instructions.d.ts +0 -1
- package/bin/instructions.js +0 -14
- package/bin/tools/getComponentHtml.d.ts +0 -3
- package/bin/tools/getComponentHtml.js +0 -30
|
@@ -5,7 +5,7 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
|
|
|
5
5
|
## Absolute Rules (VIOLATION = CRITICAL FAILURE)
|
|
6
6
|
|
|
7
7
|
1. Call ping ONCE at the start of every session before using any other tool. This loads version info and usage rules.
|
|
8
|
-
2. NEVER define or guess NCUA CSS variables (--ncua
|
|
8
|
+
2. NEVER define or guess NCUA CSS variables (--ncua-\*). NCUA component styles are controlled by CDN CSS only.
|
|
9
9
|
3. NEVER write SVG icons or icon markup manually. ALL icons MUST come from search_icon or list_icons. If you write a single svg tag by hand, you have FAILED.
|
|
10
10
|
4. NEVER use emoji in generated HTML, CSS, or any output. No exceptions.
|
|
11
11
|
5. You MUST generate NCUA component HTML using render_to_html or render_to_html_batch only. Never write component HTML/CSS manually.
|
|
@@ -30,20 +30,29 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
|
|
|
30
30
|
|
|
31
31
|
### Step 1: Component Discovery
|
|
32
32
|
|
|
33
|
+
- Call **list_components** first to see all available components and their categories. This prevents reinventing components that already exist.
|
|
33
34
|
- Use **search_component** with Korean/English keywords to find the right component
|
|
34
35
|
- Example: "비밀번호" → password-input, "모달" → modal
|
|
35
36
|
- **IMPORTANT**: input is for plain text only. For passwords, always use password-input.
|
|
36
37
|
|
|
37
38
|
### Step 2: Props Check
|
|
38
39
|
|
|
39
|
-
- Use **get_component_props** to see available properties
|
|
40
|
+
- Use **get_component_props** to see available properties BEFORE calling render_to_html.
|
|
40
41
|
- Pass all required props. Choose enum values only from the allowed list.
|
|
42
|
+
- Check `type: "object"` props carefully — they often expect arrays or structured objects. Read the `rawType` field for the actual TypeScript type.
|
|
43
|
+
- For icon props (`leadingIcon`, `trailingIcon`, `icon`), pass `{ type: "icon", icon: "IconName" }` where IconName is a PascalCase name from search_icon.
|
|
41
44
|
|
|
42
45
|
### Step 3: HTML Generation
|
|
43
46
|
|
|
44
47
|
- When building a page with multiple components, use **render_to_html_batch** to render them all in one call (max 30). This is much more efficient than calling render_to_html repeatedly.
|
|
45
48
|
- For a single component, use **render_to_html**.
|
|
46
49
|
- Use the returned html as-is. Do NOT modify class names, structure, or attributes.
|
|
50
|
+
- Check the `warnings` field in the response — it reports invalid enum values and missing required props.
|
|
51
|
+
- **If render returns empty or minimal HTML**, the component likely needs data props (e.g. `menus` for HorizontalTab, `items` for BreadCrumb). Check get_component_props and retry with proper data.
|
|
52
|
+
- **Empty result recovery** (max 3 attempts):
|
|
53
|
+
1. Call get_component_props to identify required/data props
|
|
54
|
+
2. Retry render_to_html with meaningful prop values
|
|
55
|
+
3. If still empty after 3 attempts, report the issue to the user
|
|
47
56
|
|
|
48
57
|
### Step 4: CDN Inclusion
|
|
49
58
|
|
|
@@ -55,6 +64,9 @@ You are an agent that builds UI using NCUA (NCDS UI Admin) design system compone
|
|
|
55
64
|
|
|
56
65
|
- Combine render_to_html results to build the final page
|
|
57
66
|
- Do NOT modify NCUA component styles
|
|
67
|
+
- Apply spacing between components using NCUA design tokens: call **get_design_tokens** with category "spacing" to get available spacing values (e.g. var(--spacing-4), var(--spacing-8))
|
|
68
|
+
- When deciding component size or hierarchy, document your reasoning in an HTML comment (e.g. `<!-- size:sm chosen for compact sidebar layout -->`)
|
|
69
|
+
- If a commerce-rag MCP server is available, query it for page layout patterns and spacing guidelines specific to the project
|
|
58
70
|
|
|
59
71
|
## Building Custom Areas (not covered by NCUA)
|
|
60
72
|
|
|
@@ -54,5 +54,6 @@
|
|
|
54
54
|
],
|
|
55
55
|
"category": [
|
|
56
56
|
"Component categories follow the design team standard (DES INDEX): action, input, icon, overlay, navigation, feedback, data-display."
|
|
57
|
-
]
|
|
57
|
+
],
|
|
58
|
+
"pingReminder": "IMPORTANT: You have NOT called ping yet. Call ping ONCE before proceeding. Rules: (1) NEVER hardcode hex/rgb colors - use get_design_tokens. (2) NEVER write SVG icons - use search_icon. (3) Use render_to_html or render_to_html_batch for all NCUA components. (4) For custom CSS, prefer NCUA tokens. Call get_design_tokens first."
|
|
58
59
|
}
|
package/bin/server.js
CHANGED
|
@@ -48,7 +48,7 @@ const loadToolDefinitions = (definitionsDir) => {
|
|
|
48
48
|
}
|
|
49
49
|
return { descriptions, capabilities };
|
|
50
50
|
};
|
|
51
|
-
/** rules.json을 로딩하여 flat
|
|
51
|
+
/** rules.json을 로딩하여 flat 배열 + pingReminder 반환 */
|
|
52
52
|
const loadRules = (definitionsDir) => {
|
|
53
53
|
const raw = fs_1.default.readFileSync(path_1.default.join(definitionsDir, 'rules.json'), 'utf-8');
|
|
54
54
|
const data = JSON.parse(raw);
|
|
@@ -63,22 +63,23 @@ const loadRules = (definitionsDir) => {
|
|
|
63
63
|
'compliance',
|
|
64
64
|
'category',
|
|
65
65
|
];
|
|
66
|
-
const
|
|
66
|
+
const flat = [];
|
|
67
67
|
for (const key of RULE_KEYS) {
|
|
68
68
|
const arr = data[key];
|
|
69
69
|
if (!Array.isArray(arr)) {
|
|
70
70
|
throw new Error(`rules.json에 ${key} 배열이 없습니다`);
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
flat.push(...arr);
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
const pingReminder = typeof data.pingReminder === 'string' ? data.pingReminder : '';
|
|
75
|
+
return { flat, pingReminder };
|
|
75
76
|
};
|
|
76
77
|
// ── 진입점 ───────────────────────────────────────────────────────────────────
|
|
77
78
|
const main = async () => {
|
|
78
79
|
const definitionsDir = path_1.default.resolve(__dirname, 'definitions');
|
|
79
80
|
// ── definitions/ 로딩 (main() 안에서 실행하여 catch에 포함) ──
|
|
80
81
|
const { descriptions, capabilities } = loadToolDefinitions(definitionsDir);
|
|
81
|
-
const rules = loadRules(definitionsDir);
|
|
82
|
+
const { flat: rules, pingReminder } = loadRules(definitionsDir);
|
|
82
83
|
const instructions = (0, dataLoader_js_1.loadInstructions)(definitionsDir);
|
|
83
84
|
const complianceRules = (0, dataLoader_js_1.loadComplianceRules)(definitionsDir);
|
|
84
85
|
const jsApiMap = (0, dataLoader_js_1.loadJsApi)(definitionsDir);
|
|
@@ -91,6 +92,16 @@ const main = async () => {
|
|
|
91
92
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
92
93
|
const bundle = require(bundlePath);
|
|
93
94
|
logger_js_1.logger.info('컴포넌트 번들 로딩 완료');
|
|
95
|
+
// 아이콘 번들 로딩 — renderToHtml에서 아이콘 이름 문자열을 실제 React 컴포넌트로 resolve
|
|
96
|
+
let iconBundle = {};
|
|
97
|
+
try {
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
99
|
+
iconBundle = require('@ncds/ui-admin-icon');
|
|
100
|
+
logger_js_1.logger.info('아이콘 번들 로딩 완료');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
logger_js_1.logger.warn('아이콘 번들 로딩 실패 — 아이콘 resolve 비활성화');
|
|
104
|
+
}
|
|
94
105
|
// ── DOM + React 런타임 초기화 (유틸로 추출 § 3.9) ──
|
|
95
106
|
const reactRuntime = (0, domEnvironment_js_1.setupDomEnvironment)();
|
|
96
107
|
logger_js_1.logger.info('DOM + React 런타임 초기화 완료');
|
|
@@ -103,18 +114,12 @@ const main = async () => {
|
|
|
103
114
|
const listIconsResponse = (0, listIcons_js_1.buildListIconsResponse)(iconSummary);
|
|
104
115
|
// ── MCP 서버 생성 + tool 등록 ──
|
|
105
116
|
const server = new mcp_js_1.McpServer({ name: 'ncds-ui-admin', version: version_js_1.VERSION }, { instructions });
|
|
106
|
-
// ping 호출 추적 — 미호출 시 다른 tool 응답에 핵심 rules 주입
|
|
117
|
+
// ping 호출 추적 — 미호출 시 다른 tool 응답에 핵심 rules 주입 (세션 레벨 상태, let 의도적 사용)
|
|
107
118
|
let pinged = false;
|
|
108
|
-
const PING_REMINDER = '\n\n⚠ IMPORTANT: You have NOT called ping yet. Call ping ONCE before proceeding. ' +
|
|
109
|
-
'Rules: (1) NEVER hardcode hex/rgb colors — use get_design_tokens. ' +
|
|
110
|
-
'(2) NEVER write SVG icons — use search_icon. ' +
|
|
111
|
-
'(3) Use render_to_html or render_to_html_batch for all NCUA components. ' +
|
|
112
|
-
'(4) For custom CSS, prefer NCUA tokens. Call get_design_tokens first.';
|
|
113
119
|
const withPingReminder = (response) => {
|
|
114
|
-
if (pinged)
|
|
120
|
+
if (pinged || !pingReminder)
|
|
115
121
|
return response;
|
|
116
|
-
|
|
117
|
-
return { ...response, content: [{ type: 'text', text }] };
|
|
122
|
+
return (0, response_js_1.appendPingReminder)(response, pingReminder);
|
|
118
123
|
};
|
|
119
124
|
server.registerTool('ping', { description: descriptions['ping'] }, () => {
|
|
120
125
|
pinged = true;
|
|
@@ -155,6 +160,7 @@ const main = async () => {
|
|
|
155
160
|
}, ({ name, props }) => withPingReminder((0, renderToHtml_js_1.renderToHtml)({
|
|
156
161
|
componentMap,
|
|
157
162
|
bundle,
|
|
163
|
+
iconBundle,
|
|
158
164
|
cdnMeta,
|
|
159
165
|
iconMeta,
|
|
160
166
|
reactRuntime,
|
|
@@ -178,6 +184,7 @@ const main = async () => {
|
|
|
178
184
|
const results = components.map(({ name, props }) => (0, renderToHtml_js_1.renderToHtml)({
|
|
179
185
|
componentMap,
|
|
180
186
|
bundle,
|
|
187
|
+
iconBundle,
|
|
181
188
|
cdnMeta,
|
|
182
189
|
iconMeta,
|
|
183
190
|
reactRuntime,
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* DOM 환경과 React/ReactDOM은 server.ts에서 초기화하고 파라미터로 전달한다.
|
|
5
5
|
* 이 파일에는 fs, path, require, let이 없다.
|
|
6
6
|
*/
|
|
7
|
-
import type { ComponentData, ReactRuntime } from '../types.js';
|
|
8
|
-
import type { CdnMeta, IconMeta
|
|
7
|
+
import type { ComponentData, ReactRuntime, JsApiInfo } from '../types.js';
|
|
8
|
+
import type { CdnMeta, IconMeta } from '../utils/dataLoader.js';
|
|
9
9
|
import { type McpToolResponse } from '../utils/response.js';
|
|
10
10
|
/** render_to_html tool 파라미터 */
|
|
11
11
|
export interface RenderToHtmlParams {
|
|
12
12
|
componentMap: Map<string, ComponentData>;
|
|
13
13
|
bundle: Record<string, unknown>;
|
|
14
|
+
iconBundle: Record<string, unknown>;
|
|
14
15
|
cdnMeta: CdnMeta | null;
|
|
15
16
|
iconMeta: IconMeta | null;
|
|
16
17
|
reactRuntime: ReactRuntime;
|
|
@@ -6,95 +6,95 @@ const response_js_1 = require("../utils/response.js");
|
|
|
6
6
|
/** React 특수 props 차단 — injection 방지 */
|
|
7
7
|
const BLOCKED_PROPS = new Set(['dangerouslySetInnerHTML', 'ref', '__self', '__source']);
|
|
8
8
|
/** 아이콘 관련 props — React 변환 시 import 생성 대상 */
|
|
9
|
-
const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon']);
|
|
10
|
-
// ──
|
|
11
|
-
/**
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const defaults = {};
|
|
18
|
-
for (const [key, spec] of Object.entries(propsSpec)) {
|
|
19
|
-
if (spec.default !== undefined && !(key in userProps)) {
|
|
20
|
-
defaults[key] = spec.default;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return defaults;
|
|
24
|
-
};
|
|
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';
|
|
16
|
+
// ── Props 변환 ──────────────────────────────────────────────────────
|
|
25
17
|
/** BLOCKED_PROPS를 제거 — props spec 유무와 무관하게 항상 적용 */
|
|
26
|
-
const removeBlockedProps = (props) =>
|
|
27
|
-
const safe = {};
|
|
28
|
-
for (const [key, value] of Object.entries(props)) {
|
|
29
|
-
if (!BLOCKED_PROPS.has(key))
|
|
30
|
-
safe[key] = value;
|
|
31
|
-
}
|
|
32
|
-
return safe;
|
|
33
|
-
};
|
|
18
|
+
const removeBlockedProps = (props) => Object.fromEntries(Object.entries(props).filter(([key]) => !BLOCKED_PROPS.has(key)));
|
|
34
19
|
/** componentData.props에 정의된 키 + children만 허용하는 화이트리스트 필터 */
|
|
35
20
|
const sanitizeProps = (userProps, propsSpec) => {
|
|
36
21
|
const allowedKeys = new Set([...Object.keys(propsSpec), 'children']);
|
|
37
|
-
|
|
38
|
-
for (const [key, value] of Object.entries(userProps)) {
|
|
39
|
-
if (allowedKeys.has(key))
|
|
40
|
-
sanitized[key] = value;
|
|
41
|
-
}
|
|
42
|
-
return sanitized;
|
|
22
|
+
return Object.fromEntries(Object.entries(userProps).filter(([key]) => allowedKeys.has(key)));
|
|
43
23
|
};
|
|
44
|
-
/**
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (key === 'children')
|
|
24
|
+
/** 아이콘 prop의 icon 문자열을 실제 React 컴포넌트로 resolve */
|
|
25
|
+
const resolveIconProps = (props, iconBundle) => {
|
|
26
|
+
const resolved = { ...props };
|
|
27
|
+
for (const key of ICON_PROP_NAMES) {
|
|
28
|
+
const iconObj = resolved[key];
|
|
29
|
+
if (!isIconSlot(iconObj))
|
|
51
30
|
continue;
|
|
52
|
-
const
|
|
53
|
-
if (typeof
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
else if (typeof value === 'string') {
|
|
57
|
-
attrs.push(`${key}="${escapeJsxAttrValue(value)}"`);
|
|
58
|
-
}
|
|
59
|
-
else if (typeof value === 'number') {
|
|
60
|
-
attrs.push(`${key}={${value}}`);
|
|
61
|
-
}
|
|
62
|
-
else if (ICON_PROP_NAMES.has(key) && typeof value === 'object' && value !== null) {
|
|
63
|
-
const iconObj = value;
|
|
64
|
-
if (iconObj.type === 'icon' && typeof iconObj.icon === 'string') {
|
|
65
|
-
attrs.push(`${key}={{ type: 'icon', icon: ${iconObj.icon} }}`);
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
attrs.push(`${key}={${JSON.stringify(value)}}`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
else if (spec?.type === 'enum' || spec?.type === 'string') {
|
|
72
|
-
attrs.push(`${key}="${String(value)}"`);
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
attrs.push(`${key}={${JSON.stringify(value)}}`);
|
|
31
|
+
const iconComponent = iconBundle[iconObj.icon];
|
|
32
|
+
if (typeof iconComponent === 'function') {
|
|
33
|
+
resolved[key] = { ...iconObj, icon: iconComponent };
|
|
76
34
|
}
|
|
77
35
|
}
|
|
78
|
-
return
|
|
36
|
+
return resolved;
|
|
37
|
+
};
|
|
38
|
+
// ── JSX 변환 ──────────────────────────────────────────────────────
|
|
39
|
+
/** JSX attribute 값에서 " 를 이스케이프 */
|
|
40
|
+
const escapeJsxAttrValue = (value) => value.replace(/"/g, '"');
|
|
41
|
+
/** 단일 prop을 JSX attribute 문자열로 변환 */
|
|
42
|
+
const toJsxAttr = (key, value, spec) => {
|
|
43
|
+
if (typeof value === 'boolean' && value)
|
|
44
|
+
return key;
|
|
45
|
+
if (typeof value === 'string')
|
|
46
|
+
return `${key}="${escapeJsxAttrValue(value)}"`;
|
|
47
|
+
if (typeof value === 'number')
|
|
48
|
+
return `${key}={${value}}`;
|
|
49
|
+
if (ICON_PROP_NAMES.has(key) && isIconSlot(value))
|
|
50
|
+
return `${key}={{ type: 'icon', icon: ${value.icon} }}`;
|
|
51
|
+
if (spec?.type === 'enum' || spec?.type === 'string')
|
|
52
|
+
return `${key}="${String(value)}"`;
|
|
53
|
+
return `${key}={${JSON.stringify(value)}}`;
|
|
54
|
+
};
|
|
55
|
+
/** props 객체를 JSX attribute 문자열로 변환 */
|
|
56
|
+
const propsToJsxAttrs = (props, propsSpec) => Object.entries(props)
|
|
57
|
+
.filter(([key]) => key !== 'children')
|
|
58
|
+
.map(([key, value]) => toJsxAttr(key, value, propsSpec[key]))
|
|
59
|
+
.join(' ');
|
|
60
|
+
// ── 아이콘 추출 ──────────────────────────────────────────────────
|
|
61
|
+
/** icon prop 값에서 아이콘 이름을 추출 */
|
|
62
|
+
const extractIconName = (value) => {
|
|
63
|
+
if (isIconSlot(value))
|
|
64
|
+
return value.icon;
|
|
65
|
+
if (typeof value === 'string' && value.length > 0)
|
|
66
|
+
return value;
|
|
67
|
+
return null;
|
|
79
68
|
};
|
|
80
69
|
/** appliedProps에서 아이콘 이름을 추출 (PascalCase) */
|
|
81
|
-
const extractIconNames = (props) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
else if (typeof value === 'string' && value.length > 0) {
|
|
93
|
-
icons.push(value);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return icons;
|
|
70
|
+
const extractIconNames = (props) => Object.entries(props)
|
|
71
|
+
.filter(([key]) => ICON_PROP_NAMES.has(key))
|
|
72
|
+
.map(([, value]) => extractIconName(value))
|
|
73
|
+
.filter((name) => name !== null);
|
|
74
|
+
// ── 검증 ────────────────────────────────────────────────────────
|
|
75
|
+
/** 잘못된 enum 값을 경고 문자열로 변환 */
|
|
76
|
+
const formatInvalidEnum = (key, value, spec) => {
|
|
77
|
+
const allowed = spec.values ?? [];
|
|
78
|
+
return `Invalid enum value '${value}' for prop '${key}'. Allowed: ${allowed.join(', ')}.`;
|
|
97
79
|
};
|
|
80
|
+
/** props 검증 — enum 불일치, 필수 prop 누락을 warnings로 반환 */
|
|
81
|
+
const validateProps = (userProps, propsSpec) => {
|
|
82
|
+
const missingRequired = Object.entries(propsSpec)
|
|
83
|
+
.filter(([key, spec]) => spec.required && !(key in userProps))
|
|
84
|
+
.map(([key]) => `Required prop '${key}' is missing.`);
|
|
85
|
+
const invalidEnums = Object.entries(userProps)
|
|
86
|
+
.filter(([key, value]) => {
|
|
87
|
+
const spec = propsSpec[key];
|
|
88
|
+
return spec?.type === 'enum' && spec.values && typeof value === 'string' && !spec.values.includes(value);
|
|
89
|
+
})
|
|
90
|
+
.map(([key, value]) => formatInvalidEnum(key, value, propsSpec[key]));
|
|
91
|
+
return [...missingRequired, ...invalidEnums];
|
|
92
|
+
};
|
|
93
|
+
// ── 응답 조립 ──────────────────────────────────────────────────────
|
|
94
|
+
/** componentMap의 props 스펙에서 defaultsUsed를 자동 계산 */
|
|
95
|
+
const calcDefaultsUsed = (propsSpec, userProps) => Object.fromEntries(Object.entries(propsSpec)
|
|
96
|
+
.filter(([key, spec]) => spec.default !== undefined && !(key in userProps))
|
|
97
|
+
.map(([key, spec]) => [key, spec.default]));
|
|
98
98
|
/** React 변환 매핑 정보를 생성 */
|
|
99
99
|
const buildReactOutput = (componentData, appliedProps, iconMeta) => {
|
|
100
100
|
const { exportName, importPath, props: propsSpec } = componentData;
|
|
@@ -102,37 +102,47 @@ const buildReactOutput = (componentData, appliedProps, iconMeta) => {
|
|
|
102
102
|
const dependencies = [importPath];
|
|
103
103
|
const iconNames = extractIconNames(appliedProps);
|
|
104
104
|
if (iconNames.length > 0 && iconMeta) {
|
|
105
|
-
|
|
106
|
-
imports.push(iconImport);
|
|
105
|
+
imports.push(`import { ${iconNames.join(', ')} } from '${iconMeta.packageName}';`);
|
|
107
106
|
if (!dependencies.includes(iconMeta.packageName)) {
|
|
108
107
|
dependencies.push(iconMeta.packageName);
|
|
109
108
|
}
|
|
110
109
|
}
|
|
111
110
|
const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
|
|
111
|
+
const attrsPrefix = attrsStr ? ' ' + attrsStr : '';
|
|
112
112
|
const children = appliedProps.children;
|
|
113
113
|
const jsx = children
|
|
114
|
-
? `<${exportName}${
|
|
115
|
-
: `<${exportName}${
|
|
116
|
-
return {
|
|
117
|
-
imports,
|
|
118
|
-
jsx,
|
|
119
|
-
cssImport: `import '${importPath}/style.css';`,
|
|
120
|
-
dependencies,
|
|
121
|
-
};
|
|
114
|
+
? `<${exportName}${attrsPrefix}>${String(children)}</${exportName}>`
|
|
115
|
+
: `<${exportName}${attrsPrefix} />`;
|
|
116
|
+
return { imports, jsx, cssImport: `import '${importPath}/style.css';`, dependencies };
|
|
122
117
|
};
|
|
123
118
|
/** dataVersion 객체를 생성 */
|
|
124
|
-
const buildDataVersion = (cdnMeta, iconMeta) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
const buildDataVersion = (cdnMeta, iconMeta) => ({
|
|
120
|
+
...(cdnMeta && { '@ncds/ui-admin': cdnMeta.version }),
|
|
121
|
+
...(iconMeta && { [iconMeta.packageName]: iconMeta.version }),
|
|
122
|
+
});
|
|
123
|
+
/** jsRequired에 따라 js 필드를 생성 */
|
|
124
|
+
const buildJsField = (componentData, jsApiMap) => {
|
|
125
|
+
if (!componentData.jsRequired)
|
|
126
|
+
return { required: false };
|
|
127
|
+
const jsApi = jsApiMap.get(componentData.exportName);
|
|
128
|
+
return {
|
|
129
|
+
required: true,
|
|
130
|
+
description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다',
|
|
131
|
+
...(jsApi && {
|
|
132
|
+
api: {
|
|
133
|
+
className: jsApi.className,
|
|
134
|
+
constructor: jsApi.constructor,
|
|
135
|
+
constructorParams: jsApi.constructorParams,
|
|
136
|
+
methods: jsApi.methods,
|
|
137
|
+
example: jsApi.example,
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
131
141
|
};
|
|
132
142
|
// ── 진입점 ────────────────────────────────────────────────────────
|
|
133
143
|
/** render_to_html tool — props로 React 컴포넌트를 렌더링하여 HTML + React 매핑 반환 */
|
|
134
144
|
const renderToHtml = (params) => {
|
|
135
|
-
const { componentMap, bundle, cdnMeta, iconMeta, reactRuntime, jsApiMap, name, props } = params;
|
|
145
|
+
const { componentMap, bundle, iconBundle, cdnMeta, iconMeta, reactRuntime, jsApiMap, name, props } = params;
|
|
136
146
|
const normalized = (0, response_js_1.normalizeName)(name);
|
|
137
147
|
const componentData = componentMap.get(normalized);
|
|
138
148
|
if (!componentData)
|
|
@@ -141,47 +151,30 @@ const renderToHtml = (params) => {
|
|
|
141
151
|
if (!exportName) {
|
|
142
152
|
return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (yarn extract).');
|
|
143
153
|
}
|
|
144
|
-
const Component =
|
|
154
|
+
const Component = bundle[exportName] ?? null;
|
|
145
155
|
if (!Component) {
|
|
146
156
|
return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (yarn build:bundle).');
|
|
147
157
|
}
|
|
148
158
|
try {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const rawHtml = reactRuntime.renderToStaticMarkup(element);
|
|
159
|
+
const safeProps = removeBlockedProps(props ?? {});
|
|
160
|
+
const sanitized = componentData.props ? sanitizeProps(safeProps, componentData.props) : safeProps;
|
|
161
|
+
const resolvedProps = resolveIconProps(sanitized, iconBundle);
|
|
162
|
+
const rawHtml = reactRuntime.renderToStaticMarkup(reactRuntime.createElement(Component, resolvedProps));
|
|
154
163
|
const html = `<!-- ncua:${normalized} start -->\n${rawHtml}\n<!-- ncua:${normalized} end -->`;
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const dataVersion = buildDataVersion(cdnMeta, iconMeta);
|
|
158
|
-
const jsApi = jsApiMap.get(exportName);
|
|
159
|
-
const js = componentData.jsRequired
|
|
160
|
-
? {
|
|
161
|
-
required: true,
|
|
162
|
-
description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다',
|
|
163
|
-
...(jsApi && {
|
|
164
|
-
api: {
|
|
165
|
-
className: jsApi.className,
|
|
166
|
-
constructor: jsApi.constructor,
|
|
167
|
-
constructorParams: jsApi.constructorParams,
|
|
168
|
-
methods: jsApi.methods,
|
|
169
|
-
example: jsApi.example,
|
|
170
|
-
},
|
|
171
|
-
}),
|
|
172
|
-
}
|
|
173
|
-
: { required: false };
|
|
164
|
+
const warnings = componentData.props ? validateProps(sanitized, componentData.props) : [];
|
|
165
|
+
const hasDefaults = componentData.props ? calcDefaultsUsed(componentData.props, sanitized) : {};
|
|
174
166
|
return (0, response_js_1.successResponse)({
|
|
175
167
|
html,
|
|
176
168
|
component: normalized,
|
|
177
169
|
exportName,
|
|
178
170
|
importPath: componentData.importPath,
|
|
179
|
-
appliedProps:
|
|
180
|
-
...(Object.keys(
|
|
181
|
-
|
|
171
|
+
appliedProps: sanitized,
|
|
172
|
+
...(Object.keys(hasDefaults).length > 0 && { defaultsUsed: hasDefaults }),
|
|
173
|
+
...(warnings.length > 0 && { warnings }),
|
|
174
|
+
js: buildJsField(componentData, jsApiMap),
|
|
182
175
|
cdn: cdnMeta ?? undefined,
|
|
183
|
-
dataVersion,
|
|
184
|
-
react,
|
|
176
|
+
dataVersion: buildDataVersion(cdnMeta, iconMeta),
|
|
177
|
+
react: buildReactOutput(componentData, sanitized, iconMeta),
|
|
185
178
|
});
|
|
186
179
|
}
|
|
187
180
|
catch (err) {
|
package/bin/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface PropSpec {
|
|
|
7
7
|
default?: unknown;
|
|
8
8
|
values?: string[];
|
|
9
9
|
rawType?: string;
|
|
10
|
+
properties?: Record<string, PropSpec>;
|
|
10
11
|
}
|
|
11
12
|
export interface ComponentUsage {
|
|
12
13
|
import: string;
|
|
@@ -67,6 +68,14 @@ export interface TokenData {
|
|
|
67
68
|
categories: string[];
|
|
68
69
|
groups: TokenGroup[];
|
|
69
70
|
}
|
|
71
|
+
export interface JsApiInfo {
|
|
72
|
+
className: string;
|
|
73
|
+
constructor: string;
|
|
74
|
+
constructorParams: Record<string, string>;
|
|
75
|
+
methods: string[];
|
|
76
|
+
staticMethods?: string[];
|
|
77
|
+
example: string;
|
|
78
|
+
}
|
|
70
79
|
export interface ComplianceRuleHint {
|
|
71
80
|
attr: string;
|
|
72
81
|
values: string[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentData, IconData, TokenData, ComplianceRulesData } from '../types.js';
|
|
1
|
+
import type { ComponentData, IconData, TokenData, ComplianceRulesData, JsApiInfo } from '../types.js';
|
|
2
2
|
export declare const DEFAULT_DATA_DIR: string;
|
|
3
3
|
export interface CdnMeta {
|
|
4
4
|
version: string;
|
|
@@ -27,15 +27,6 @@ export declare const loadTokenData: (dataDir: string) => TokenData;
|
|
|
27
27
|
export declare const loadComplianceRules: (definitionsDir: string) => ComplianceRulesData | null;
|
|
28
28
|
/** definitions/instructions.md를 로드하여 문자열 반환 */
|
|
29
29
|
export declare const loadInstructions: (definitionsDir: string) => string;
|
|
30
|
-
/** JS API 정보 타입 */
|
|
31
|
-
export interface JsApiInfo {
|
|
32
|
-
className: string;
|
|
33
|
-
constructor: string;
|
|
34
|
-
constructorParams: Record<string, string>;
|
|
35
|
-
methods: string[];
|
|
36
|
-
staticMethods?: string[];
|
|
37
|
-
example: string;
|
|
38
|
-
}
|
|
39
30
|
/** definitions/js-api.json을 로드하여 exportName→JsApiInfo Map 반환 */
|
|
40
31
|
export declare const loadJsApi: (definitionsDir: string) => Map<string, JsApiInfo>;
|
|
41
32
|
/** componentMap에서 이름으로 단일 컴포넌트 조회 */
|
package/bin/utils/response.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare const successResponse: (data: unknown) => McpToolResponse;
|
|
|
20
20
|
export declare const errorResponse: (code: McpErrorCode, message: string, suggestion: string) => McpToolResponse;
|
|
21
21
|
/** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
|
|
22
22
|
export declare const componentNotFoundResponse: (name: string) => McpToolResponse;
|
|
23
|
+
/** ping 미호출 시 응답에 핵심 rules를 주입하는 래퍼 */
|
|
24
|
+
export declare const appendPingReminder: (response: McpToolResponse, reminder: string) => McpToolResponse;
|
|
23
25
|
/** 입력 정규화 — name.trim().toLowerCase() */
|
|
24
26
|
export declare const normalizeName: (name: string) => string;
|
|
25
27
|
/** catch 블록에서 에러 메시지를 안전하게 추출 */
|
package/bin/utils/response.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.toErrorMessage = exports.normalizeName = exports.componentNotFoundResponse = exports.errorResponse = exports.successResponse = void 0;
|
|
3
|
+
exports.toErrorMessage = exports.normalizeName = exports.appendPingReminder = exports.componentNotFoundResponse = exports.errorResponse = exports.successResponse = void 0;
|
|
4
4
|
/** 성공 응답 — data를 JSON.stringify하여 반환 */
|
|
5
5
|
const successResponse = (data) => ({
|
|
6
6
|
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
@@ -20,6 +20,12 @@ exports.errorResponse = errorResponse;
|
|
|
20
20
|
/** COMPONENT_NOT_FOUND 오류 — 여러 tool에서 공통 사용 */
|
|
21
21
|
const componentNotFoundResponse = (name) => (0, exports.errorResponse)('COMPONENT_NOT_FOUND', `Component '${name}' not found.`, 'Use search_component to find similar components, or list_components to see all available.');
|
|
22
22
|
exports.componentNotFoundResponse = componentNotFoundResponse;
|
|
23
|
+
/** ping 미호출 시 응답에 핵심 rules를 주입하는 래퍼 */
|
|
24
|
+
const appendPingReminder = (response, reminder) => {
|
|
25
|
+
const text = response.content[0].text + '\n\n' + reminder;
|
|
26
|
+
return { ...response, content: [{ type: 'text', text }] };
|
|
27
|
+
};
|
|
28
|
+
exports.appendPingReminder = appendPingReminder;
|
|
23
29
|
/** 입력 정규화 — name.trim().toLowerCase() */
|
|
24
30
|
const normalizeName = (name) => name.trim().toLowerCase();
|
|
25
31
|
exports.normalizeName = normalizeName;
|