@ncds/ui-admin-mcp 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (104) hide show
  1. package/README.md +3 -3
  2. package/bin/components.bundle.js +18 -9
  3. package/bin/definitions/external/editor.d.ts +50 -0
  4. package/bin/definitions/external/editor.js +53 -0
  5. package/bin/definitions/external/step-guide.d.ts +61 -0
  6. package/bin/definitions/external/step-guide.js +52 -0
  7. package/bin/definitions/instructions.md +194 -10
  8. package/bin/definitions/js-api.json +352 -0
  9. package/bin/definitions/rules.json +36 -4
  10. package/bin/definitions/tool-definitions.json +33 -9
  11. package/bin/overrides/composition.json +2473 -0
  12. package/bin/server.d.ts +5 -0
  13. package/bin/server.js +97 -33
  14. package/bin/server.mjs +0 -0
  15. package/bin/tools/external/editor.d.ts +18 -0
  16. package/bin/tools/external/editor.js +88 -0
  17. package/bin/tools/external/step-guide.d.ts +19 -0
  18. package/bin/tools/external/step-guide.js +79 -0
  19. package/bin/tools/getComponentProps.d.ts +3 -0
  20. package/bin/tools/getComponentProps.js +12 -3
  21. package/bin/tools/listCompositionOverrides.d.ts +61 -0
  22. package/bin/tools/listCompositionOverrides.js +156 -0
  23. package/bin/tools/listSidecarOverrides.d.ts +54 -0
  24. package/bin/tools/listSidecarOverrides.js +96 -0
  25. package/bin/tools/ping.d.ts +1 -1
  26. package/bin/tools/renderToHtml.d.ts +38 -7
  27. package/bin/tools/renderToHtml.js +785 -110
  28. package/bin/tools/searchComponent.d.ts +5 -0
  29. package/bin/tools/searchComponent.js +3 -3
  30. package/bin/tools/validateHtml.d.ts +8 -6
  31. package/bin/tools/validateHtml.js +74 -6
  32. package/bin/types.d.ts +60 -1
  33. package/bin/utils/bemValidator.d.ts +16 -8
  34. package/bin/utils/bemValidator.js +16 -4
  35. package/bin/utils/compliance.d.ts +7 -6
  36. package/bin/utils/compliance.js +8 -4
  37. package/bin/utils/dataLoader.d.ts +43 -14
  38. package/bin/utils/dataLoader.js +125 -22
  39. package/bin/utils/domEnvironment.js +51 -0
  40. package/bin/utils/fuzzyMatch.d.ts +4 -0
  41. package/bin/utils/fuzzyMatch.js +13 -3
  42. package/bin/utils/logger.d.ts +5 -5
  43. package/bin/utils/logger.js +5 -5
  44. package/bin/utils/response.d.ts +4 -2
  45. package/bin/utils/response.js +15 -4
  46. package/bin/utils/tokenValidator.d.ts +4 -3
  47. package/bin/utils/tokenValidator.js +13 -11
  48. package/bin/version.d.ts +4 -2
  49. package/bin/version.js +4 -2
  50. package/data/_icons.json +357 -2
  51. package/data/_meta.json +4 -5
  52. package/data/_tokens.json +8 -8
  53. package/data/badge-group.json +181 -4
  54. package/data/badge.json +146 -14
  55. package/data/block-container.json +95 -0
  56. package/data/block-header.json +208 -0
  57. package/data/bread-crumb.json +38 -2
  58. package/data/button-group.json +59 -0
  59. package/data/button.json +124 -1
  60. package/data/carousel-arrow.json +6 -11
  61. package/data/carousel-number-group.json +2 -12
  62. package/data/checkbox.json +1 -1
  63. package/data/combo-box.json +36 -8
  64. package/data/data-grid.json +240 -0
  65. package/data/date-picker.json +22 -2
  66. package/data/divider.json +1 -1
  67. package/data/dot.json +2 -2
  68. package/data/dropdown.json +187 -20
  69. package/data/editor.json +85 -0
  70. package/data/empty-state.json +168 -3
  71. package/data/featured-icon.json +20 -5
  72. package/data/file-input.json +176 -10
  73. package/data/horizontal-tab.json +219 -3
  74. package/data/image-file-input.json +177 -10
  75. package/data/input-base.json +165 -4
  76. package/data/modal.json +266 -5
  77. package/data/notification.json +56 -40
  78. package/data/number-input.json +164 -4
  79. package/data/page-title.json +135 -0
  80. package/data/pagination.json +8 -4
  81. package/data/password-input.json +252 -13
  82. package/data/progress-bar.json +28 -8
  83. package/data/progress-circle.json +9 -6
  84. package/data/radio.json +4 -3
  85. package/data/range-date-picker-with-buttons.json +187 -7
  86. package/data/range-date-picker.json +186 -6
  87. package/data/select-box.json +52 -16
  88. package/data/select.json +35 -25
  89. package/data/slider.json +1 -1
  90. package/data/spinner.json +3 -4
  91. package/data/step-guide.json +130 -0
  92. package/data/switch.json +66 -6
  93. package/data/table.json +293 -0
  94. package/data/tag.json +68 -6
  95. package/data/textarea.json +1 -1
  96. package/data/toggle.json +2 -2
  97. package/data/tooltip.json +20 -3
  98. package/data/vertical-tab.json +220 -3
  99. package/package.json +28 -26
  100. package/templates/README.md +1 -1
  101. package/bin/instructions.d.ts +0 -1
  102. package/bin/instructions.js +0 -14
  103. package/bin/tools/getComponentHtml.d.ts +0 -3
  104. package/bin/tools/getComponentHtml.js +0 -30
@@ -1,177 +1,852 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.renderToHtml = void 0;
3
+ exports.renderToHtml = exports.buildJsField = exports.mergeCdnDefaults = exports.stripFunctionProps = void 0;
4
+ /**
5
+ * render_to_html tool — 컴포넌트 속성을 전달하면 정확한 HTML + React 매핑을 반환 (순수 함수)
6
+ *
7
+ * DOM 환경과 React/ReactDOM은 server.ts에서 초기화하고 파라미터로 전달한다.
8
+ * 이 파일에는 fs, path, require, let이 없다.
9
+ */
10
+ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: 기존 spec-walk 함수 복잡도 (Story 5.8 scope 외, 별도 refactor 스토리)
11
+ // biome-ignore-all lint/style/useExportsLast: RenderToHtmlParams export interface 상단 위치 (파일 관행)
12
+ // biome-ignore-all lint/style/noNonNullAssertion: 기존 fallback 패턴 (Story 5.8 scope 외)
13
+ // biome-ignore-all lint/style/noNestedTernary: 기존 reactElementToJsx의 type 판정 로직 (Story 5.8 scope 외)
14
+ // biome-ignore-all lint/style/useTemplate: 기존 attrsPrefix 문자열 결합 (Story 5.8 scope 외)
15
+ const lodash_1 = require("lodash");
4
16
  const response_js_1 = require("../utils/response.js");
17
+ const editor_js_1 = require("./external/editor.js");
18
+ const step_guide_js_1 = require("./external/step-guide.js");
5
19
  // ── 상수 ──────────────────────────────────────────────────────────
6
20
  /** React 특수 props 차단 — injection 방지 */
7
21
  const BLOCKED_PROPS = new Set(['dangerouslySetInnerHTML', 'ref', '__self', '__source']);
8
- /** 아이콘 관련 props React 변환 시 import 생성 대상 */
9
- const ICON_PROP_NAMES = new Set(['leadingIcon', 'trailingIcon', 'icon']);
10
- // ── 순수 함수 (헬퍼) ──────────────────────────────────────────────
11
- /** componentData.exportName으로 번들에서 컴포넌트를 찾는다 */
12
- const findComponent = (bundle, exportName) => {
13
- return bundle[exportName] ?? null;
22
+ /** children 중첩 렌더링 제한 */
23
+ const MAX_CHILDREN_DEPTH = 5;
24
+ const MAX_CHILDREN_COUNT = 30;
25
+ /** spec-walk 재귀 깊이 제한 — 순환 spec 방지 */
26
+ const MAX_SPEC_DEPTH = 6;
27
+ // ── 타입 가드: children 중첩 ────────────────────────────────────────
28
+ /** { component: string, props?: object } 형태인지 판별 */
29
+ const isChildDescriptor = (value) => typeof value === 'object' && value !== null && typeof value.component === 'string';
30
+ /** PascalCase 변환: "header" → "Header" */
31
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
32
+ /** kebab-case → PascalCase 변환: "action-bar" → "ActionBar", "header-cell" → "HeaderCell" */
33
+ const kebabToPascal = (s) => s.split('-').map(capitalize).join('');
34
+ /** PascalCase → kebab-case 변환: "DataGrid" → "data-grid", "PageTitle" → "page-title" */
35
+ const pascalToKebab = (s) => s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
36
+ /** 단순 컴포넌트 resolve: "button" → { Component, propsSpec, compoundKey: null } */
37
+ const resolveSimpleComponent = (name, ctx) => {
38
+ const normalized = (0, response_js_1.normalizeName)(name);
39
+ const childData = ctx.componentMap.get(normalized);
40
+ const Component = childData?.exportName ? (ctx.bundle[childData.exportName] ?? null) : null;
41
+ return { Component, propsSpec: childData?.props, compoundKey: null };
14
42
  };
15
- /** componentMap의 props 스펙에서 defaultsUsed를 자동 계산 */
16
- const calcDefaultsUsed = (propsSpec, userProps) => {
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;
43
+ /** compound component resolve: "data-grid.action-bar" { Component: bundle.DataGrid.ActionBar, propsSpec: subComponents["DataGrid.ActionBar"].props } */
44
+ const resolveCompoundComponent = (name, dotIndex, ctx) => {
45
+ const parentName = name.slice(0, dotIndex).toLowerCase();
46
+ const subName = name.slice(dotIndex + 1);
47
+ const parentData = ctx.componentMap.get(parentName);
48
+ const parentExportName = parentData?.exportName;
49
+ if (!parentExportName)
50
+ return { Component: null, propsSpec: undefined, compoundKey: null };
51
+ const Parent = ctx.bundle[parentExportName];
52
+ // kebab→PascalCase: "action-bar" → "ActionBar". 단일 워드도 정상 동작 ("header" → "Header").
53
+ const pascalSubName = kebabToPascal(subName);
54
+ const Component = Parent?.[pascalSubName] ?? null;
55
+ // subComponents key는 "DataGrid.ActionBar" 형태 (parent export name + PascalCase sub name)
56
+ const compoundKey = `${parentExportName}.${pascalSubName}`;
57
+ const propsSpec = parentData?.subComponents?.[compoundKey]?.props;
58
+ return { Component, propsSpec, compoundKey };
59
+ };
60
+ /**
61
+ * 부모 컴포넌트의 allowedChildren[parentKey].forbiddenInContext 에 자식 compound key 가 있는지 검사.
62
+ * 위반 시 disallowedChildren 응답 entry 생성 (silent drop 의도)
63
+ */
64
+ const checkForbiddenInContext = (parentKey, childCompoundKey, parentComponentData) => {
65
+ const allowed = parentComponentData?.allowedChildren?.[parentKey];
66
+ if (!allowed || Array.isArray(allowed))
67
+ return null; // 단순 배열 형식은 forbiddenInContext 없음
68
+ const forbidden = allowed.forbiddenInContext;
69
+ if (!forbidden)
70
+ return null;
71
+ const message = forbidden[childCompoundKey];
72
+ if (!message)
73
+ return null;
74
+ return { reason: 'forbidden_in_context', message };
75
+ };
76
+ /** children JSON을 재귀적으로 React element로 변환 — silent drop 차단 + composition 제약 검증 */
77
+ const resolveChildren = (children, ctx, depth) => {
78
+ if (depth > MAX_CHILDREN_DEPTH)
79
+ return null;
80
+ // 문자열/숫자/boolean — 그대로 반환
81
+ if (typeof children === 'string' || typeof children === 'number' || typeof children === 'boolean')
82
+ return children;
83
+ // 배열 — 각 요소를 재귀 처리 (MAX_CHILDREN_COUNT 제한, undefined/null 결과 filter)
84
+ if (Array.isArray(children)) {
85
+ const limited = children.slice(0, MAX_CHILDREN_COUNT);
86
+ const results = [];
87
+ limited.forEach((child, idx) => {
88
+ const childCtx = { ...ctx, path: `${ctx.path}[${idx}]` };
89
+ const resolved = resolveChildren(child, childCtx, depth);
90
+ if (resolved !== null && resolved !== undefined)
91
+ results.push(resolved);
92
+ });
93
+ return results;
94
+ }
95
+ // { component, props?, children? } — React element로 변환
96
+ if (isChildDescriptor(children)) {
97
+ const componentName = children.component.trim();
98
+ const dotIndex = componentName.indexOf('.');
99
+ // dot notation: "data-grid.action-bar" → bundle.DataGrid.ActionBar (+ subComponents props spec)
100
+ const resolved = dotIndex > 0
101
+ ? resolveCompoundComponent(componentName, dotIndex, ctx)
102
+ : resolveSimpleComponent(componentName, ctx);
103
+ const { Component, propsSpec, compoundKey } = resolved;
104
+ // ── 갭 해소 1: forbiddenInContext 검증 (resolve 가 성공해도 컨텍스트 위반은 차단) ──
105
+ if (Component && compoundKey && ctx.parentKey) {
106
+ // ctx.parentKey 는 PascalCase ("DataGrid" 또는 "DataGrid.Table") — componentMap 키는 kebab-case 이므로 변환
107
+ const rootParentExport = ctx.parentKey.split('.')[0]; // "DataGrid"
108
+ const parentName = pascalToKebab(rootParentExport); // "data-grid"
109
+ const parentData = ctx.componentMap.get(parentName);
110
+ const forbidden = checkForbiddenInContext(ctx.parentKey, compoundKey, parentData);
111
+ if (forbidden) {
112
+ ctx.disallowedChildren.push({
113
+ path: ctx.path,
114
+ parent: ctx.parentKey,
115
+ child: compoundKey,
116
+ reason: forbidden.reason,
117
+ message: forbidden.message,
118
+ });
119
+ ctx.warnings.push(`${componentName} 은(는) ${ctx.parentKey} 안에서 사용할 수 없음: ${forbidden.message}`);
120
+ return null; // silent drop
121
+ }
122
+ }
123
+ // ── 갭 해소 2: lookup 실패 → unknownChildren 보고 (silent drop 차단) ──
124
+ if (!Component) {
125
+ const reason = dotIndex > 0 ? 'unknown_subcomponent' : 'unknown_component';
126
+ ctx.unknownChildren.push({ path: ctx.path, component: componentName, reason });
127
+ ctx.warnings.push(reason === 'unknown_subcomponent'
128
+ ? `'${componentName}' 은(는) 알 수 없는 sub-component. get_component_props 로 subComponents 확인 후 정정.`
129
+ : `'${componentName}' 은(는) 알 수 없는 component. list_components 로 정식 이름 확인 후 정정.`);
130
+ return null;
131
+ }
132
+ // BLOCKED_PROPS(dangerouslySetInnerHTML, ref, __self, __source)를 먼저 제거
133
+ // propsSpec이 있으면 화이트리스트 필터(sanitizeProps)도 적용하여 XSS/alien prop 차단
134
+ const rawProps = children.props ?? {};
135
+ const blockedRemoved = removeBlockedProps(rawProps);
136
+ const childProps = propsSpec ? sanitizeProps(blockedRemoved, propsSpec) : blockedRemoved;
137
+ // descriptor의 children 필드가 있으면 props.children보다 우선
138
+ const rawChildren = children.children !== undefined ? children.children : childProps.children;
139
+ if (rawChildren !== undefined) {
140
+ // 자식 재귀 시 parentKey 전파 — forbiddenInContext 검증에 사용
141
+ const nestedCtx = {
142
+ ...ctx,
143
+ parentKey: compoundKey ?? ctx.parentKey,
144
+ path: `${ctx.path}>${componentName}`,
145
+ };
146
+ childProps.children = resolveChildren(rawChildren, nestedCtx, depth + 1);
21
147
  }
148
+ const resolvedProps = resolveIconProps(childProps, ctx.iconBundle, propsSpec);
149
+ return ctx.reactRuntime.createElement(Component, resolvedProps);
22
150
  }
23
- return defaults;
151
+ return children;
24
152
  };
153
+ // ── Props 변환 ──────────────────────────────────────────────────────
25
154
  /** 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
- };
155
+ const removeBlockedProps = (props) => Object.fromEntries(Object.entries(props).filter(([key]) => !BLOCKED_PROPS.has(key)));
34
156
  /** componentData.props에 정의된 키 + children만 허용하는 화이트리스트 필터 */
35
157
  const sanitizeProps = (userProps, propsSpec) => {
36
158
  const allowedKeys = new Set([...Object.keys(propsSpec), 'children']);
37
- const sanitized = {};
38
- for (const [key, value] of Object.entries(userProps)) {
39
- if (allowedKeys.has(key))
40
- sanitized[key] = value;
159
+ return Object.fromEntries(Object.entries(userProps).filter(([key]) => allowedKeys.has(key)));
160
+ };
161
+ /**
162
+ * spec-walk 재귀 resolver — spec.semantic === 'icon-component' 위치에서 문자열을 iconBundle 컴포넌트로 치환
163
+ *
164
+ * 이름 화이트리스트가 아닌 **타입 정체성(semantic)** 기반 판정.
165
+ * extract 단계에서 ts-morph가 SlotIconComponent alias를 감지하여 spec.semantic을 부착한다.
166
+ * 중첩 객체/배열을 spec.properties를 따라 자동 순회하므로 어떤 깊이에도 대응.
167
+ */
168
+ const resolveValueBySpec = (value, spec, iconBundle, depth = 0) => {
169
+ if (depth > MAX_SPEC_DEPTH)
170
+ return value;
171
+ if (!spec)
172
+ return value;
173
+ // leaf: icon-component 위치에서 string → 컴포넌트 치환. bundle miss 시 원본 유지.
174
+ if (spec.semantic === 'icon-component' && typeof value === 'string') {
175
+ const iconComponent = iconBundle[value];
176
+ return typeof iconComponent === 'function' ? iconComponent : value;
41
177
  }
42
- return sanitized;
178
+ // 배열: spec이 배열 타입이고 value가 배열이면 각 요소에 재귀 (spec.properties는 요소 shape)
179
+ if (spec.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
180
+ return value.map((item) => {
181
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
182
+ const obj = item;
183
+ const resolved = { ...obj };
184
+ for (const [key, childSpec] of Object.entries(spec.properties)) {
185
+ if (key in resolved) {
186
+ resolved[key] = resolveValueBySpec(resolved[key], childSpec, iconBundle, depth + 1);
187
+ }
188
+ }
189
+ return resolved;
190
+ }
191
+ return item;
192
+ });
193
+ }
194
+ // 중첩 객체: spec.properties를 따라 각 키에 재귀 적용
195
+ if (spec.properties && value && typeof value === 'object' && !Array.isArray(value)) {
196
+ const obj = value;
197
+ const resolved = { ...obj };
198
+ for (const [key, childSpec] of Object.entries(spec.properties)) {
199
+ if (key in resolved) {
200
+ resolved[key] = resolveValueBySpec(resolved[key], childSpec, iconBundle, depth + 1);
201
+ }
202
+ }
203
+ return resolved;
204
+ }
205
+ return value;
43
206
  };
207
+ /** 최상위 props를 propsSpec에 따라 재귀 resolve */
208
+ const resolveIconProps = (props, iconBundle, propsSpec) => {
209
+ if (!propsSpec)
210
+ return props;
211
+ const resolved = { ...props };
212
+ for (const [key, spec] of Object.entries(propsSpec)) {
213
+ if (key in resolved) {
214
+ resolved[key] = resolveValueBySpec(resolved[key], spec, iconBundle);
215
+ }
216
+ }
217
+ return resolved;
218
+ };
219
+ // ── JSX 변환 ──────────────────────────────────────────────────────
44
220
  /** JSX attribute 값에서 " 를 이스케이프 */
45
221
  const escapeJsxAttrValue = (value) => value.replace(/"/g, '"');
46
- /** props 값을 JSX attribute 문자열로 변환 */
47
- const propsToJsxAttrs = (props, propsSpec) => {
48
- const attrs = [];
49
- for (const [key, value] of Object.entries(props)) {
50
- if (key === 'children')
51
- continue;
222
+ /**
223
+ * spec-walk 재귀로 JSX 문자열을 생성.
224
+ * icon-component 위치의 문자열은 컴포넌트 이름 참조(중괄호 없는 identifier)로,
225
+ * 중첩 객체는 `{ key: ... }` object literal 형태로 직렬화.
226
+ */
227
+ const toJsxValueBySpec = (value, spec, depth = 0) => {
228
+ if (depth > MAX_SPEC_DEPTH)
229
+ return JSON.stringify(value);
230
+ // leaf: icon-component 위치의 문자열은 identifier로 반환
231
+ if (spec?.semantic === 'icon-component' && typeof value === 'string')
232
+ return value;
233
+ // 배열: 각 요소를 재귀 직렬화
234
+ if (spec?.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
235
+ const items = value.map((item) => {
236
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
237
+ const obj = item;
238
+ const entries = Object.entries(obj).map(([k, v]) => {
239
+ const childSpec = spec.properties?.[k];
240
+ return `${k}: ${toJsxValueBySpec(v, childSpec, depth + 1)}`;
241
+ });
242
+ return `{ ${entries.join(', ')} }`;
243
+ }
244
+ return JSON.stringify(item);
245
+ });
246
+ return `[${items.join(', ')}]`;
247
+ }
248
+ // 중첩 객체: spec.properties 따라 각 키를 재귀 직렬화
249
+ if (spec?.properties && value && typeof value === 'object' && !Array.isArray(value)) {
250
+ const obj = value;
251
+ const entries = Object.entries(obj).map(([k, v]) => {
252
+ const childSpec = spec.properties?.[k];
253
+ return `${k}: ${toJsxValueBySpec(v, childSpec, depth + 1)}`;
254
+ });
255
+ return `{ ${entries.join(', ')} }`;
256
+ }
257
+ if (typeof value === 'string')
258
+ return JSON.stringify(value);
259
+ return JSON.stringify(value);
260
+ };
261
+ /** 단일 prop을 JSX attribute 문자열로 변환 — spec-driven */
262
+ const toJsxAttr = (key, value, spec) => {
263
+ if (typeof value === 'boolean' && value)
264
+ return key;
265
+ // icon-component 최상위 prop — key={SearchLg} 형태
266
+ if (spec?.semantic === 'icon-component' && typeof value === 'string')
267
+ return `${key}={${value}}`;
268
+ // 배열 prop — key={[{ ... }, { ... }]} 형태
269
+ if (spec?.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
270
+ return `${key}={${toJsxValueBySpec(value, spec)}}`;
271
+ }
272
+ // 중첩 spec 객체 — key={{ icon: X, color: 'success', ... }} 형태 (JSX 표현식 중괄호로 감싸기)
273
+ if (spec?.properties && value && typeof value === 'object' && !Array.isArray(value)) {
274
+ return `${key}={${toJsxValueBySpec(value, spec)}}`;
275
+ }
276
+ if (typeof value === 'string')
277
+ return `${key}="${escapeJsxAttrValue(value)}"`;
278
+ if (typeof value === 'number')
279
+ return `${key}={${value}}`;
280
+ if (spec?.type === 'enum' || spec?.type === 'string')
281
+ return `${key}="${String(value)}"`;
282
+ return `${key}={${JSON.stringify(value)}}`;
283
+ };
284
+ /** props 객체를 JSX attribute 문자열로 변환 */
285
+ const propsToJsxAttrs = (props, propsSpec) => Object.entries(props)
286
+ .filter(([key]) => key !== 'children')
287
+ .map(([key, value]) => toJsxAttr(key, value, propsSpec[key]))
288
+ .join(' ');
289
+ // ── 아이콘 추출 ──────────────────────────────────────────────────
290
+ /** spec-walk로 value에서 icon-component 위치의 문자열 이름을 수집. 중첩 객체 + 배열까지 내려감. */
291
+ const collectIconNamesBySpec = (value, spec, acc, depth = 0) => {
292
+ if (depth > MAX_SPEC_DEPTH || !spec)
293
+ return;
294
+ if (spec.semantic === 'icon-component' && typeof value === 'string' && value.length > 0) {
295
+ acc.add(value);
296
+ return;
297
+ }
298
+ // 배열 요소 재귀
299
+ if (spec.rawType?.includes('[]') && spec.properties && Array.isArray(value)) {
300
+ for (const item of value) {
301
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
302
+ const obj = item;
303
+ for (const [key, childSpec] of Object.entries(spec.properties)) {
304
+ if (key in obj)
305
+ collectIconNamesBySpec(obj[key], childSpec, acc, depth + 1);
306
+ }
307
+ }
308
+ }
309
+ return;
310
+ }
311
+ if (spec.properties && value && typeof value === 'object' && !Array.isArray(value)) {
312
+ const obj = value;
313
+ for (const [key, childSpec] of Object.entries(spec.properties)) {
314
+ if (key in obj) {
315
+ collectIconNamesBySpec(obj[key], childSpec, acc, depth + 1);
316
+ }
317
+ }
318
+ }
319
+ };
320
+ /** propsSpec을 따라 appliedProps에서 아이콘 이름을 수집 — 이름 화이트리스트 없음 */
321
+ const extractIconNames = (props, propsSpec) => {
322
+ if (!propsSpec)
323
+ return [];
324
+ const acc = new Set();
325
+ for (const [key, spec] of Object.entries(propsSpec)) {
326
+ if (key in props)
327
+ collectIconNamesBySpec(props[key], spec, acc);
328
+ }
329
+ return [...acc];
330
+ };
331
+ // ── 검증 ────────────────────────────────────────────────────────
332
+ /** 잘못된 enum 값을 경고 문자열로 변환 — 교정 결과도 포함 */
333
+ const formatInvalidEnum = (key, value, spec) => {
334
+ const allowed = spec.values ?? [];
335
+ const correction = spec.default !== undefined ? ` Corrected to default '${spec.default}'.` : ' Removed (no default).';
336
+ return `Invalid enum value '${value}' for prop '${key}'.${correction} Allowed: ${allowed.join(', ')}.`;
337
+ };
338
+ /** props 검증 — enum 불일치, 필수 prop 누락을 warnings로 반환 */
339
+ const validateProps = (userProps, propsSpec) => {
340
+ const missingRequired = Object.entries(propsSpec)
341
+ .filter(([key, spec]) => spec.required && !(key in userProps))
342
+ .map(([key]) => `Required prop '${key}' is missing.`);
343
+ const invalidEnums = Object.entries(userProps)
344
+ .filter(([key, value]) => {
52
345
  const spec = propsSpec[key];
53
- if (typeof value === 'boolean' && value) {
54
- attrs.push(key);
346
+ return spec?.type === 'enum' && spec.values && typeof value === 'string' && !spec.values.includes(value);
347
+ })
348
+ .map(([key, value]) => formatInvalidEnum(key, value, propsSpec[key]));
349
+ const missingArrays = Object.entries(propsSpec)
350
+ .filter(([key, spec]) => spec.rawType?.includes('[]') && !(key in userProps))
351
+ .map(([key]) => `Array prop '${key}' is missing. Filled with empty array []. Pass data for meaningful rendering.`);
352
+ return [...missingRequired, ...invalidEnums, ...missingArrays];
353
+ };
354
+ /** invalid enum 값을 default 또는 제거로 교정 — 할루시네이션 HTML 방지 */
355
+ const correctInvalidEnums = (userProps, propsSpec) => {
356
+ const corrected = { ...userProps };
357
+ for (const [key, value] of Object.entries(corrected)) {
358
+ const spec = propsSpec[key];
359
+ if (spec?.type !== 'enum' || !spec.values || typeof value !== 'string')
360
+ continue;
361
+ if (spec.values.includes(value))
362
+ continue;
363
+ if (spec.default !== undefined) {
364
+ corrected[key] = spec.default;
365
+ }
366
+ else {
367
+ delete corrected[key];
368
+ }
369
+ }
370
+ return corrected;
371
+ };
372
+ /** required prop 누락 시 type별 safe default 주입 — React crash 방지 (fillMissingArrayProps 통합) */
373
+ const correctMissingRequired = (userProps, propsSpec) => {
374
+ const corrected = { ...userProps };
375
+ for (const [key, spec] of Object.entries(propsSpec)) {
376
+ // array prop — required 여부와 무관하게 [] 주입 (.map() crash 방지)
377
+ if (spec.rawType?.includes('[]') && !(key in corrected && Array.isArray(corrected[key]))) {
378
+ corrected[key] = [];
379
+ continue;
380
+ }
381
+ // required가 아니거나 이미 값이 있으면 스킵
382
+ if (!spec.required || key in corrected)
383
+ continue;
384
+ // default가 있으면 사용
385
+ if (spec.default !== undefined) {
386
+ corrected[key] = spec.default;
387
+ continue;
388
+ }
389
+ // icon-component 타입 — 빈 span 반환 스텁 주입하여 crash 방지 (spec.semantic 기반 판정)
390
+ if (spec.semantic === 'icon-component') {
391
+ corrected[key] = () => null;
392
+ continue;
393
+ }
394
+ // 일반 function/ReactNode는 HTML에 영향 없으므로 주입하지 않음
395
+ if (spec.type === 'function' || spec.type === 'ReactNode')
396
+ continue;
397
+ // type별 safe default
398
+ const safeDefaults = {
399
+ string: '',
400
+ object: {},
401
+ boolean: false,
402
+ number: 0,
403
+ enum: spec.values?.[0] ?? '',
404
+ };
405
+ if (spec.type in safeDefaults) {
406
+ corrected[key] = safeDefaults[spec.type];
407
+ }
408
+ }
409
+ return corrected;
410
+ };
411
+ // ── CDN init 패턴 (Story 5.8) ─────────────────────────────────────
412
+ /**
413
+ * CDN 호출 패턴은 `js-api.json`의 `cdnPattern` 필드(A/B/C)로 단일 관리한다.
414
+ * 컴포넌트 추가 시 js-api.json 한 곳에만 패턴을 명시하면 자동 인식된다.
415
+ */
416
+ /** options를 안전하게 직렬화 — script 블록 탈출 방지(`</script>` 이스케이프) */
417
+ const serializeCdnOptions = (options) => JSON.stringify(options).replace(/<\/script/gi, '<\\/script');
418
+ /** 패턴 A: `new ncua.X(wrapper, options)` */
419
+ const buildPatternA = (id, className, options) => [
420
+ `<div id="${id}"></div>`,
421
+ '<script>',
422
+ " document.addEventListener('DOMContentLoaded', function () {",
423
+ ` const wrapper = document.querySelector('#${id}');`,
424
+ ` new ncua.${className}(wrapper, ${serializeCdnOptions(options)});`,
425
+ ' });',
426
+ '</script>',
427
+ ].join('\n');
428
+ /** 패턴 B: `new ncua.X({...options, container: id})` — container 자동 주입 */
429
+ const buildPatternB = (id, className, options) => [
430
+ `<div id="${id}"></div>`,
431
+ '<script>',
432
+ " document.addEventListener('DOMContentLoaded', function () {",
433
+ ` new ncua.${className}(${serializeCdnOptions({ ...options, container: id })});`,
434
+ ' });',
435
+ '</script>',
436
+ ].join('\n');
437
+ /** 패턴 C: `new ncua.X(options)` + `getElement()` + 수동 append */
438
+ const buildPatternC = (id, className, options) => [
439
+ `<div id="${id}"></div>`,
440
+ '<script>',
441
+ " document.addEventListener('DOMContentLoaded', function () {",
442
+ ` const wrapper = document.querySelector('#${id}');`,
443
+ ` const inst = new ncua.${className}(${serializeCdnOptions(options)});`,
444
+ ' wrapper.appendChild(inst.getElement());',
445
+ ' });',
446
+ '</script>',
447
+ ].join('\n');
448
+ /**
449
+ * CDN init 스크립트 생성. 패턴은 `jsApi.cdnPattern` (A/B/C)으로 결정한다.
450
+ * 호출자는 `cdnPattern` 부재 시 isCdnInput=false로 분기하여 React renderToStaticMarkup 경로를 사용한다.
451
+ */
452
+ const buildCdnInitScript = (pattern, componentName, instanceId, className, options) => {
453
+ const id = `ncua-${componentName}-${instanceId}`;
454
+ if (pattern === 'A')
455
+ return buildPatternA(id, className, options);
456
+ if (pattern === 'B')
457
+ return buildPatternB(id, className, options);
458
+ return buildPatternC(id, className, options);
459
+ };
460
+ /** options 키 중 `jsApi.constructorParams`에 없는 키를 warning 메시지로 변환.
461
+ * constructorParams의 키는 `config.options` 같은 dot 표기 — 마지막 segment만 추출해 비교. */
462
+ const checkUnknownCdnKeys = (options, jsApi) => {
463
+ if (Object.keys(jsApi.constructorParams).length === 0)
464
+ return [];
465
+ const allowedKeys = new Set();
466
+ for (const param of Object.keys(jsApi.constructorParams)) {
467
+ const lastDot = param.lastIndexOf('.');
468
+ allowedKeys.add(lastDot >= 0 ? param.slice(lastDot + 1) : param);
469
+ }
470
+ return Object.keys(options)
471
+ .filter((key) => !allowedKeys.has(key))
472
+ .map((key) => `Unknown CDN option key '${key}' for component '${jsApi.className}'. Passed through as-is. Verify against constructorParams.`);
473
+ };
474
+ /** SelectBox/ComboBox `options` 배열 요소 키 정규화.
475
+ * 공식 docs는 `{id, label}` 또는 `{id, text}` 허용. React 매칭은 `label`만 받으므로
476
+ * 통일된 `{id, label}`로 정규화. `{value, text}`는 비표준 alias(HTML `<option>` 컨벤션
477
+ * 오용)이며 함께 변환. 정규화 발생 시 warning 기록(에이전트 학습용). */
478
+ const normalizeCdnInputProps = (componentName, cdnConfig) => {
479
+ if (componentName !== 'select-box' && componentName !== 'combo-box') {
480
+ return { normalized: cdnConfig, warnings: [] };
481
+ }
482
+ const { options } = cdnConfig;
483
+ if (!Array.isArray(options))
484
+ return { normalized: cdnConfig, warnings: [] };
485
+ const aliasesUsed = new Set();
486
+ const normalizedOptions = options.map((opt) => {
487
+ if (opt === null || typeof opt !== 'object')
488
+ return opt;
489
+ const item = { ...opt };
490
+ if (item.id === undefined && item.value !== undefined) {
491
+ item.id = item.value;
492
+ delete item.value;
493
+ aliasesUsed.add('value');
55
494
  }
56
- else if (typeof value === 'string') {
57
- attrs.push(`${key}="${escapeJsxAttrValue(value)}"`);
495
+ if (item.label === undefined && item.text !== undefined) {
496
+ item.label = item.text;
497
+ delete item.text;
498
+ aliasesUsed.add('text');
58
499
  }
59
- else if (typeof value === 'number') {
60
- attrs.push(`${key}={${value}}`);
500
+ return item;
501
+ });
502
+ const warnings = [];
503
+ if (aliasesUsed.has('value')) {
504
+ warnings.push(`Option key 'value' normalized to 'id' for component '${componentName}'. Canonical form is {id, label}.`);
505
+ }
506
+ if (aliasesUsed.has('text')) {
507
+ warnings.push(`Option key 'text' normalized to 'label' for component '${componentName}'. Canonical form is {id, label}.`);
508
+ }
509
+ return { normalized: { ...cdnConfig, options: normalizedOptions }, warnings };
510
+ };
511
+ /** Story 5.9: 사용자 props 에서 함수 prop 재귀 제거 (JSON 직렬화·deep merge 안전).
512
+ * cdnDefaults 는 JSON 이라 함수 없음 → 사용자 props 만 정리.
513
+ * Story 5.10: 외부 분기 모듈(`tools/external/editor.ts`)에서도 재사용 → export. */
514
+ const stripFunctionProps = (obj) => {
515
+ if (typeof obj === 'function')
516
+ return undefined;
517
+ if (Array.isArray(obj)) {
518
+ return obj.map((item) => (0, exports.stripFunctionProps)(item)).filter((item) => item !== undefined);
519
+ }
520
+ if (typeof obj === 'object' && obj !== null) {
521
+ return Object.fromEntries(Object.entries(obj)
522
+ .filter(([, value]) => typeof value !== 'function')
523
+ .map(([key, value]) => [key, (0, exports.stripFunctionProps)(value)]));
524
+ }
525
+ return obj;
526
+ };
527
+ exports.stripFunctionProps = stripFunctionProps;
528
+ /** Story 5.9: cdnDefaults 와 사용자 props 를 deep merge.
529
+ * - 사용자 값 우선 (lodash merge 의 두 번째 인자가 우선)
530
+ * - 함수 prop 은 사용자 props 에서 stripFunctionProps 로 사전 제거
531
+ * - DatePicker `datePickerOptions` 배열 한정: 각 항목의 `options` 에 cdnDefaults 의 첫 항목 options 를 per-element 부분 merge
532
+ * - 다른 배열(`buttons` 등) 은 사용자 값 그대로
533
+ * 반환: { merged, defaultsApplied } — defaultsApplied 는 cdnDefaults 에 있고 사용자 props 에 없는 top-level 키 (defaultsUsed 응답용)
534
+ * Story 5.10: 외부 분기 모듈(`tools/external/editor.ts`)에서도 재사용 → export. */
535
+ const mergeCdnDefaults = (componentName, cdnDefaults, userProps) => {
536
+ if (!cdnDefaults)
537
+ return { merged: userProps, defaultsApplied: [] };
538
+ const userPropsClean = (0, exports.stripFunctionProps)(userProps);
539
+ const baseMerged = (0, lodash_1.merge)((0, lodash_1.cloneDeep)(cdnDefaults), userPropsClean);
540
+ const merged = componentName === 'date-picker' && Array.isArray(userPropsClean.datePickerOptions)
541
+ ? {
542
+ ...baseMerged,
543
+ datePickerOptions: userPropsClean.datePickerOptions.map((item) => {
544
+ const baseOptions = cdnDefaults.datePickerOptions?.[0]?.options ?? {};
545
+ return {
546
+ ...item,
547
+ options: (0, lodash_1.merge)((0, lodash_1.cloneDeep)(baseOptions), item.options ?? {}),
548
+ };
549
+ }),
61
550
  }
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} }}`);
551
+ : baseMerged;
552
+ const defaultsApplied = Object.keys(cdnDefaults).filter((key) => !(key in userPropsClean));
553
+ return { merged, defaultsApplied };
554
+ };
555
+ exports.mergeCdnDefaults = mergeCdnDefaults;
556
+ /** CDN config(에이전트 입력) → React props (buildReactOutput 입력) 번역.
557
+ * 단순 컴포넌트는 identity, 차이가 있는 컴포넌트는 별도 룰. */
558
+ const translateCdnToReactProps = (componentName, cdnConfig) => {
559
+ // SelectBox/ComboBox: options → optionItems, value(원시) → value(객체) (배열에서 매칭해 wrap)
560
+ if (componentName === 'select-box' || componentName === 'combo-box') {
561
+ const { options, value, ...rest } = cdnConfig;
562
+ const result = { ...rest };
563
+ if (options !== undefined)
564
+ result.optionItems = options;
565
+ if (value !== undefined) {
566
+ if ((typeof value === 'string' || typeof value === 'number') && Array.isArray(options)) {
567
+ const matched = options.find((opt) => typeof opt === 'object' && opt !== null && opt.id === value);
568
+ result.value = matched !== undefined ? matched : value;
66
569
  }
67
570
  else {
68
- attrs.push(`${key}={${JSON.stringify(value)}}`);
571
+ result.value = value;
69
572
  }
70
573
  }
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)}}`);
76
- }
574
+ return result;
77
575
  }
78
- return attrs.join(' ');
576
+ // FileInput/ImageFileInput: container 키는 React props에 없음 → 제거
577
+ if (componentName === 'file-input' || componentName === 'image-file-input') {
578
+ const result = { ...cdnConfig };
579
+ delete result.container;
580
+ return result;
581
+ }
582
+ // 그 외: identity (Slider, DatePicker, Tooltip, Notification, ProgressBar, Tag — key 동일 가정)
583
+ return cdnConfig;
79
584
  };
80
- /** appliedProps에서 아이콘 이름을 추출 (PascalCase) */
81
- const extractIconNames = (props) => {
82
- const icons = [];
83
- for (const [key, value] of Object.entries(props)) {
84
- if (!ICON_PROP_NAMES.has(key))
85
- continue;
86
- if (typeof value === 'object' && value !== null) {
87
- const iconObj = value;
88
- if (iconObj.type === 'icon' && typeof iconObj.icon === 'string') {
89
- icons.push(iconObj.icon);
585
+ // ── 응답 조립 ──────────────────────────────────────────────────────
586
+ /** componentMap의 props 스펙에서 defaultsUsed를 자동 계산 */
587
+ const calcDefaultsUsed = (propsSpec, userProps) => Object.fromEntries(Object.entries(propsSpec)
588
+ .filter(([key, spec]) => spec.default !== undefined && !(key in userProps))
589
+ .map(([key, spec]) => [key, spec.default]));
590
+ /** descriptor의 component 이름에서 해당 컴포넌트의 propsSpec을 조회 */
591
+ const lookupPropsSpecByComponentName = (componentName, componentMap) => {
592
+ const dotIndex = componentName.indexOf('.');
593
+ if (dotIndex > 0) {
594
+ const parentName = componentName.slice(0, dotIndex).toLowerCase();
595
+ const subName = componentName.slice(dotIndex + 1);
596
+ const parentData = componentMap.get(parentName);
597
+ if (!parentData?.exportName)
598
+ return undefined;
599
+ const compoundKey = `${parentData.exportName}.${kebabToPascal(subName)}`;
600
+ return parentData.subComponents?.[compoundKey]?.props;
601
+ }
602
+ return componentMap.get((0, response_js_1.normalizeName)(componentName))?.props;
603
+ };
604
+ /** descriptor/React element 트리를 JSX 문자열로 변환. spec을 따라 icon-component를 식별자로 직렬화하고 사용된 icon 이름을 누적 */
605
+ const reactElementToJsx = (node, ctx) => {
606
+ if (node == null)
607
+ return '';
608
+ if (typeof node === 'string')
609
+ return node;
610
+ if (typeof node === 'number' || typeof node === 'boolean')
611
+ return String(node);
612
+ if (Array.isArray(node))
613
+ return node.map((n) => reactElementToJsx(n, ctx)).join('\n');
614
+ // Component descriptor: { component, props, children } — compound component 포함
615
+ if (typeof node === 'object' && 'component' in node) {
616
+ const desc = node;
617
+ const componentName = desc.component.trim();
618
+ // "data-grid.action-bar" → "DataGrid.ActionBar" (kebab→PascalCase per dot segment)
619
+ const pascalName = componentName.split('.').map(kebabToPascal).join('.');
620
+ const propsSpec = lookupPropsSpecByComponentName(componentName, ctx.componentMap);
621
+ // 이 descriptor에서 사용된 icon 이름 수집 (imports 생성용)
622
+ if (propsSpec && desc.props) {
623
+ for (const [key, spec] of Object.entries(propsSpec)) {
624
+ if (key in desc.props)
625
+ collectIconNamesBySpec(desc.props[key], spec, ctx.iconNames);
90
626
  }
91
627
  }
92
- else if (typeof value === 'string' && value.length > 0) {
93
- icons.push(value);
628
+ const { children: descChildren, ...restProps } = desc.props || {};
629
+ const attrs = Object.entries(restProps)
630
+ .filter(([, v]) => v !== undefined)
631
+ .map(([k, v]) => toJsxAttr(k, v, propsSpec?.[k]))
632
+ .join(' ');
633
+ const attrsStr = attrs ? ` ${attrs}` : '';
634
+ const inner = desc.children ?? descChildren;
635
+ if (inner != null) {
636
+ return `<${pascalName}${attrsStr}>${reactElementToJsx(inner, ctx)}</${pascalName}>`;
94
637
  }
638
+ return `<${pascalName}${attrsStr} />`;
95
639
  }
96
- return icons;
640
+ // React element: { type, props, ... } — spec 조회 불가 → 최소 직렬화
641
+ if (typeof node === 'object' && 'type' in node) {
642
+ const el = node;
643
+ const name = typeof el.type === 'string'
644
+ ? el.type
645
+ : typeof el.type === 'function'
646
+ ? el.type.displayName ||
647
+ el.type.name ||
648
+ 'Component'
649
+ : 'Component';
650
+ const { children: childChildren, ...restProps } = el.props || {};
651
+ const attrs = Object.entries(restProps)
652
+ .filter(([, v]) => v !== undefined)
653
+ .map(([k, v]) => {
654
+ if (typeof v === 'boolean' && v)
655
+ return k;
656
+ if (typeof v === 'string')
657
+ return `${k}="${v}"`;
658
+ return `${k}={${JSON.stringify(v)}}`;
659
+ })
660
+ .join(' ');
661
+ const attrsStr = attrs ? ` ${attrs}` : '';
662
+ if (childChildren != null) {
663
+ return `<${name}${attrsStr}>${reactElementToJsx(childChildren, ctx)}</${name}>`;
664
+ }
665
+ return `<${name}${attrsStr} />`;
666
+ }
667
+ return String(node);
97
668
  };
98
- /** React 변환 매핑 정보를 생성 */
99
- const buildReactOutput = (componentData, appliedProps, iconMeta) => {
669
+ /** React 변환 매핑 정보를 생성 — top-level + 중첩 descriptor에서 사용된 icon을 모두 수집 */
670
+ const buildReactOutput = (componentData, appliedProps, iconMeta, componentMap) => {
100
671
  const { exportName, importPath, props: propsSpec } = componentData;
101
- const imports = [`import { ${exportName} } from '${importPath}';`];
102
672
  const dependencies = [importPath];
103
- const iconNames = extractIconNames(appliedProps);
104
- if (iconNames.length > 0 && iconMeta) {
105
- const iconImport = `import { ${iconNames.join(', ')} } from '${iconMeta.packageName}';`;
106
- imports.push(iconImport);
673
+ // top-level props에서 icon 이름 수집
674
+ const iconNames = new Set(extractIconNames(appliedProps, propsSpec));
675
+ const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
676
+ const attrsPrefix = attrsStr ? ' ' + attrsStr : '';
677
+ const children = appliedProps.children;
678
+ // children 트리 직렬화 — 그 과정에서 중첩 descriptor의 icon도 누적
679
+ const jsxCtx = { componentMap, iconNames };
680
+ const jsx = children
681
+ ? `<${exportName}${attrsPrefix}>${reactElementToJsx(children, jsxCtx)}</${exportName}>`
682
+ : `<${exportName}${attrsPrefix} />`;
683
+ const imports = [`import { ${exportName} } from '${importPath}';`];
684
+ if (iconNames.size > 0 && iconMeta) {
685
+ imports.push(`import { ${[...iconNames].join(', ')} } from '${iconMeta.packageName}';`);
107
686
  if (!dependencies.includes(iconMeta.packageName)) {
108
687
  dependencies.push(iconMeta.packageName);
109
688
  }
110
689
  }
111
- const attrsStr = propsSpec ? propsToJsxAttrs(appliedProps, propsSpec) : '';
112
- const children = appliedProps.children;
113
- const jsx = children
114
- ? `<${exportName}${attrsStr ? ' ' + attrsStr : ''}>${String(children)}</${exportName}>`
115
- : `<${exportName}${attrsStr ? ' ' + attrsStr : ''} />`;
116
- return {
117
- imports,
118
- jsx,
119
- cssImport: `import '${importPath}/style.css';`,
120
- dependencies,
121
- };
690
+ return { imports, jsx, cssImport: `import '${importPath}/style.css';`, dependencies };
122
691
  };
123
692
  /** dataVersion 객체를 생성 */
124
- const buildDataVersion = (cdnMeta, iconMeta) => {
125
- const versions = {};
126
- if (cdnMeta)
127
- versions['@ncds/ui-admin'] = cdnMeta.version;
128
- if (iconMeta)
129
- versions[iconMeta.packageName] = iconMeta.version;
130
- return versions;
693
+ const buildDataVersion = (cdnMeta, iconMeta) => ({
694
+ ...(cdnMeta && { '@ncds/ui-admin': cdnMeta.version }),
695
+ ...(iconMeta && { [iconMeta.packageName]: iconMeta.version }),
696
+ });
697
+ /** jsRequired에 따라 js 필드를 생성 */
698
+ const buildJsField = (componentData, jsApiMap) => {
699
+ if (!componentData.jsRequired)
700
+ return { required: false };
701
+ const jsApi = jsApiMap.get(componentData.exportName);
702
+ return {
703
+ required: true,
704
+ description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다',
705
+ ...(jsApi && {
706
+ api: {
707
+ className: jsApi.className,
708
+ constructor: jsApi.constructor,
709
+ constructorParams: jsApi.constructorParams,
710
+ methods: jsApi.methods,
711
+ example: jsApi.example,
712
+ },
713
+ }),
714
+ };
131
715
  };
716
+ exports.buildJsField = buildJsField;
132
717
  // ── 진입점 ────────────────────────────────────────────────────────
133
718
  /** render_to_html tool — props로 React 컴포넌트를 렌더링하여 HTML + React 매핑 반환 */
134
719
  const renderToHtml = (params) => {
135
- const { componentMap, bundle, cdnMeta, iconMeta, reactRuntime, name, props } = params;
720
+ const { componentMap, bundle, iconBundle, cdnMeta, iconMeta, reactRuntime, jsApiMap, name, props, instanceId } = params;
136
721
  const normalized = (0, response_js_1.normalizeName)(name);
722
+ // Story 5.10: editor alias 검출 시 ui-admin 흐름 우회 — 외부 분기에서 응답 직접 조립
723
+ if ((0, editor_js_1.isEditorAlias)(normalized)) {
724
+ return (0, editor_js_1.buildEditorResponse)(props ?? {}, instanceId);
725
+ }
726
+ // Story 6.1: step-guide alias 검출 시 ui-admin 흐름 우회 — 외부 분기 (함수 호출 형식)
727
+ if ((0, step_guide_js_1.isStepGuideAlias)(normalized)) {
728
+ return (0, step_guide_js_1.buildStepGuideResponse)(props ?? {}, instanceId);
729
+ }
137
730
  const componentData = componentMap.get(normalized);
138
731
  if (!componentData)
139
732
  return (0, response_js_1.componentNotFoundResponse)(normalized);
140
733
  const exportName = componentData.exportName;
141
734
  if (!exportName) {
142
- return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (yarn extract).');
735
+ return (0, response_js_1.errorResponse)('EXPORT_NAME_MISSING', `'${normalized}' 컴포넌트에 exportName이 없습니다.`, '데이터를 재추출해주세요 (pnpm extract).');
143
736
  }
144
- const Component = findComponent(bundle, exportName);
737
+ const Component = bundle[exportName] ?? null;
145
738
  if (!Component) {
146
- return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (yarn build:bundle).');
739
+ return (0, response_js_1.errorResponse)('COMPONENT_NOT_IN_BUNDLE', `'${normalized}' (${exportName}) 컴포넌트가 번들에 없습니다.`, '번들을 재빌드해주세요 (pnpm build:bundle).');
147
740
  }
148
741
  try {
149
- const rawProps = props ?? {};
150
- const safeProps = removeBlockedProps(rawProps);
151
- const userProps = componentData.props ? sanitizeProps(safeProps, componentData.props) : safeProps;
152
- const element = reactRuntime.createElement(Component, userProps);
153
- const rawHtml = reactRuntime.renderToStaticMarkup(element);
154
- const html = `<!-- ncua:${normalized} -->\n${rawHtml}\n<!-- /ncua:${normalized} -->`;
155
- const defaultsUsed = componentData.props ? calcDefaultsUsed(componentData.props, userProps) : {};
156
- const react = buildReactOutput(componentData, userProps, iconMeta);
157
- const dataVersion = buildDataVersion(cdnMeta, iconMeta);
742
+ // Story 5.8: jsRequired + js-api 정의 + jsApi.cdnPattern 등록 → CDN-input 모드
743
+ const jsApi = jsApiMap.get(exportName);
744
+ const isCdnInput = componentData.jsRequired && jsApi !== undefined && jsApi.cdnPattern !== undefined;
745
+ // CDN-input은 입력이 CDN config 스키마 → 옵션 배열 키 정규화 → React 파이프라인을 위해 React props로 번역
746
+ const cdnNormalization = isCdnInput
747
+ ? normalizeCdnInputProps(normalized, props ?? {})
748
+ : { normalized: props ?? {}, warnings: [] };
749
+ const normalizedCdnProps = cdnNormalization.normalized;
750
+ // Story 5.9: cdnDefaults 와 deep merge (사용자 props 우선, 함수 prop skip, datePickerOptions per-element 부분 merge)
751
+ const cdnMergeResult = isCdnInput && jsApi
752
+ ? (0, exports.mergeCdnDefaults)(normalized, jsApi.cdnDefaults, normalizedCdnProps)
753
+ : { merged: normalizedCdnProps, defaultsApplied: [] };
754
+ const mergedCdnProps = cdnMergeResult.merged;
755
+ const reactInput = isCdnInput ? translateCdnToReactProps(normalized, mergedCdnProps) : mergedCdnProps;
756
+ const safeProps = removeBlockedProps(reactInput);
757
+ const sanitized = componentData.props ? sanitizeProps(safeProps, componentData.props) : safeProps;
758
+ const enumWarnings = componentData.props ? validateProps(sanitized, componentData.props) : [];
759
+ const enumCorrected = componentData.props ? correctInvalidEnums(sanitized, componentData.props) : sanitized;
760
+ const corrected = componentData.props ? correctMissingRequired(enumCorrected, componentData.props) : enumCorrected;
761
+ const postWarnings = componentData.props ? validateProps(corrected, componentData.props) : [];
762
+ const cdnKeyWarnings = isCdnInput && jsApi ? checkUnknownCdnKeys(normalizedCdnProps, jsApi) : [];
763
+ const warnings = [
764
+ ...enumWarnings,
765
+ ...postWarnings.filter((w) => !enumWarnings.includes(w)),
766
+ ...cdnKeyWarnings,
767
+ ...cdnNormalization.warnings,
768
+ ];
769
+ const resolvedProps = resolveIconProps(corrected, iconBundle, componentData.props);
770
+ // P12: children + 모든 ReactNode prop 에 대해 component descriptor 자동 resolve.
771
+ // PageTitle.primaryAction / Modal.actions / BlockHeader.tooltip 같은 ReactNode prop 에
772
+ // {component, props, children} descriptor 또는 그 배열을 넘기면 React element 로 변환.
773
+ // children 외 ReactNode prop 도 동일 흐름 — godomall5 결과물에서 PageTitle action 이
774
+ // 빈 상태로 떨어진 근본 원인(ReactNode prop 자동 resolve 부재) 해결.
775
+ // Epic 7: resolveChildren 결과(unknown/disallowed/추가 warnings)를 누적할 ctx 초기화.
776
+ // ctx 는 children 처리 전체에서 공유되어 silent drop 차단 + composition 제약 검증을 수행한다.
777
+ const renderCtx = {
778
+ bundle,
779
+ iconBundle,
780
+ componentMap,
781
+ reactRuntime,
782
+ warnings: [],
783
+ unknownChildren: [],
784
+ disallowedChildren: [],
785
+ parentKey: exportName, // 최상위 부모 키 (compound parent lookup 시작점)
786
+ path: normalized,
787
+ };
788
+ if (resolvedProps.children !== undefined || componentData.props) {
789
+ if (resolvedProps.children !== undefined) {
790
+ resolvedProps.children = resolveChildren(resolvedProps.children, renderCtx, 0);
791
+ }
792
+ // children 이외의 ReactNode prop 들 — propsSpec.type === 'ReactNode' 인 키 전부 처리
793
+ if (componentData.props) {
794
+ for (const [key, spec] of Object.entries(componentData.props)) {
795
+ if (key === 'children')
796
+ continue; // 위에서 처리됨
797
+ if (spec.type !== 'ReactNode')
798
+ continue;
799
+ const val = resolvedProps[key];
800
+ if (val === undefined || val === null)
801
+ continue;
802
+ // string / number / boolean primitive 는 React 가 직접 렌더 — 건드리지 않음
803
+ if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
804
+ continue;
805
+ // component descriptor 또는 그 배열인 경우만 resolve
806
+ resolvedProps[key] = resolveChildren(val, renderCtx, 0);
807
+ }
808
+ }
809
+ }
810
+ // HTML 출력: CDN-input은 init script, 그 외는 React 정적 HTML (renderToStaticMarkup은 필요할 때만 호출)
811
+ const cdnInitScript = isCdnInput && jsApi?.cdnPattern
812
+ ? buildCdnInitScript(jsApi.cdnPattern, normalized, instanceId, jsApi.className, removeBlockedProps(mergedCdnProps))
813
+ : null;
814
+ const html = cdnInitScript !== null
815
+ ? cdnInitScript
816
+ : `<!-- ncua:${normalized} start -->\n${reactRuntime.renderToStaticMarkup(reactRuntime.createElement(Component, resolvedProps))}\n<!-- ncua:${normalized} end -->`;
817
+ // Story 5.1-8: jsRequired 컴포넌트는 React data 의 default 무시 (vanilla 정의에 없는 React-only default 누수 차단).
818
+ // React 컴포넌트는 기존 동작 유지.
819
+ const isJsRequired = componentData.jsRequired === true;
820
+ const hasDefaults = !isJsRequired && componentData.props ? calcDefaultsUsed(componentData.props, corrected) : {};
821
+ // Story 5.9: cdnDefaults 적용된 키도 defaultsUsed 에 노출 (에이전트 학습용)
822
+ const cdnDefaultsApplied = isCdnInput && jsApi?.cdnDefaults
823
+ ? Object.fromEntries(cdnMergeResult.defaultsApplied.map((key) => [key, jsApi.cdnDefaults?.[key]]))
824
+ : {};
825
+ const finalDefaultsUsed = { ...hasDefaults, ...cdnDefaultsApplied };
826
+ // Epic 7: prop warnings + children resolve warnings 통합
827
+ const allWarnings = [...warnings, ...renderCtx.warnings];
158
828
  return (0, response_js_1.successResponse)({
159
829
  html,
160
830
  component: normalized,
161
831
  exportName,
162
832
  importPath: componentData.importPath,
163
- appliedProps: userProps,
164
- ...(Object.keys(defaultsUsed).length > 0 && { defaultsUsed }),
165
- js: componentData.jsRequired
166
- ? { required: true, description: '이 컴포넌트는 인터랙션을 위해 CDN JS가 필요합니다' }
167
- : { required: false },
833
+ appliedProps: corrected,
834
+ ...(Object.keys(finalDefaultsUsed).length > 0 && { defaultsUsed: finalDefaultsUsed }),
835
+ ...(allWarnings.length > 0 && { warnings: allWarnings }),
836
+ ...(renderCtx.unknownChildren.length > 0 && { unknownChildren: renderCtx.unknownChildren }),
837
+ ...(renderCtx.disallowedChildren.length > 0 && { disallowedChildren: renderCtx.disallowedChildren }),
838
+ js: (0, exports.buildJsField)(componentData, jsApiMap),
168
839
  cdn: cdnMeta ?? undefined,
169
- dataVersion,
170
- react,
840
+ dataVersion: buildDataVersion(cdnMeta, iconMeta),
841
+ react: buildReactOutput(componentData, corrected, iconMeta, componentMap),
171
842
  });
172
843
  }
173
844
  catch (err) {
174
- return (0, response_js_1.errorResponse)('RENDER_FAILED', `'${normalized}' 렌더링 실패: ${(0, response_js_1.toErrorMessage)(err)}`, '사용 가능한 속성을 확인하려면 get_component_props를 사용하세요.');
845
+ const catchWarnings = componentData.props ? validateProps(props ?? {}, componentData.props) : [];
846
+ const suggestion = componentData.usageExamples
847
+ ? 'get_component_props로 usageExamples를 확인하고 해당 props로 재시도하세요.'
848
+ : '사용 가능한 속성을 확인하려면 get_component_props를 사용하세요.';
849
+ return (0, response_js_1.errorResponse)('RENDER_FAILED', `'${normalized}' 렌더링 실패: ${(0, response_js_1.toErrorMessage)(err)}`, suggestion, catchWarnings.length > 0 ? catchWarnings : undefined);
175
850
  }
176
851
  };
177
852
  exports.renderToHtml = renderToHtml;