@ncds/ui-admin-mcp 1.0.0-alpha.18 → 1.0.0-alpha.20

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 (39) hide show
  1. package/bin/components.bundle.js +8 -8
  2. package/bin/definitions/js-api.json +39 -4
  3. package/bin/definitions/rules.json +33 -3
  4. package/bin/definitions/tool-definitions.json +8 -0
  5. package/bin/overrides/composition.json +2500 -0
  6. package/bin/server.js +6 -1
  7. package/bin/tools/getComponentProps.js +5 -0
  8. package/bin/tools/listCompositionOverrides.d.ts +61 -0
  9. package/bin/tools/listCompositionOverrides.js +156 -0
  10. package/bin/tools/renderToHtml.d.ts +14 -0
  11. package/bin/tools/renderToHtml.js +128 -24
  12. package/bin/tools/validateHtml.js +68 -0
  13. package/bin/types.d.ts +30 -0
  14. package/bin/utils/bemValidator.d.ts +7 -1
  15. package/bin/utils/dataLoader.d.ts +31 -5
  16. package/bin/utils/dataLoader.js +97 -5
  17. package/bin/version.d.ts +1 -1
  18. package/bin/version.js +1 -1
  19. package/data/badge-group.json +3 -3
  20. package/data/badge.json +2 -2
  21. package/data/bread-crumb.json +1 -1
  22. package/data/button.json +2 -2
  23. package/data/combo-box.json +11 -3
  24. package/data/date-picker.json +1 -1
  25. package/data/dropdown.json +5 -5
  26. package/data/empty-state.json +3 -3
  27. package/data/horizontal-tab.json +4 -4
  28. package/data/modal.json +1 -1
  29. package/data/notification.json +6 -1
  30. package/data/page-title.json +1 -1
  31. package/data/progress-bar.json +1 -1
  32. package/data/range-date-picker-with-buttons.json +4 -4
  33. package/data/range-date-picker.json +4 -4
  34. package/data/select-box.json +11 -3
  35. package/data/select.json +2 -2
  36. package/data/tag.json +1 -1
  37. package/data/tooltip.json +9 -0
  38. package/data/vertical-tab.json +5 -5
  39. package/package.json +2 -2
package/bin/server.js CHANGED
@@ -20,6 +20,7 @@ const zod_1 = require("zod");
20
20
  const getComponentProps_js_1 = require("./tools/getComponentProps.js");
21
21
  const getDesignTokens_js_1 = require("./tools/getDesignTokens.js");
22
22
  const listComponents_js_1 = require("./tools/listComponents.js");
23
+ const listCompositionOverrides_js_1 = require("./tools/listCompositionOverrides.js");
23
24
  const listIcons_js_1 = require("./tools/listIcons.js");
24
25
  const ping_js_1 = require("./tools/ping.js");
25
26
  const renderToHtml_js_1 = require("./tools/renderToHtml.js");
@@ -63,6 +64,8 @@ const loadRules = (definitionsDir) => {
63
64
  'cdn',
64
65
  'react',
65
66
  'forbidden',
67
+ 'composition',
68
+ 'fontFamily',
66
69
  'customArea',
67
70
  'compliance',
68
71
  'category',
@@ -88,7 +91,7 @@ const main = async () => {
88
91
  const complianceRules = (0, dataLoader_js_1.loadComplianceRules)(definitionsDir);
89
92
  const jsApiMap = (0, dataLoader_js_1.loadJsApi)(definitionsDir);
90
93
  // ── 데이터 로딩 ──
91
- const componentMap = (0, dataLoader_js_1.loadComponentsFromDir)(dataLoader_js_1.DEFAULT_DATA_DIR);
94
+ const { map: componentMap, compositionOverrides } = (0, dataLoader_js_1.loadComponentsFromDir)(dataLoader_js_1.DEFAULT_DATA_DIR);
92
95
  const { cdn: cdnMeta, icon: iconMeta } = (0, dataLoader_js_1.loadMeta)(dataLoader_js_1.DEFAULT_DATA_DIR);
93
96
  const iconData = (0, dataLoader_js_1.loadIconData)(dataLoader_js_1.DEFAULT_DATA_DIR);
94
97
  const tokenData = (0, dataLoader_js_1.loadTokenData)(dataLoader_js_1.DEFAULT_DATA_DIR);
@@ -116,6 +119,7 @@ const main = async () => {
116
119
  const iconSummary = (0, listIcons_js_1.buildIconSummary)(iconData);
117
120
  const listComponentsResponse = (0, listComponents_js_1.buildListComponentsResponse)(groupedComponents);
118
121
  const listIconsResponse = (0, listIcons_js_1.buildListIconsResponse)(iconSummary);
122
+ const listCompositionOverridesResponse = (0, listCompositionOverrides_js_1.buildListCompositionOverridesResponse)(compositionOverrides, componentMap);
119
123
  // ── MCP 서버 생성 + tool 등록 ──
120
124
  const server = new mcp_js_1.McpServer({ name: 'ncds-ui-admin', version: version_js_1.VERSION }, { instructions });
121
125
  // ping 호출 추적 — 미호출 시 다른 tool 응답에 핵심 rules 주입 (세션 레벨 상태, let 의도적 사용)
@@ -135,6 +139,7 @@ const main = async () => {
135
139
  inputSchema: { query: zod_1.z.string().describe('Search keyword (e.g. "search", "alert", "arrow")') },
136
140
  }, ({ query }) => withPingReminder((0, searchIcon_js_1.searchIcon)(iconData, query)));
137
141
  server.registerTool('list_components', { description: descriptions['list_components'] }, () => withPingReminder((0, listComponents_js_1.listComponents)(listComponentsResponse)));
142
+ server.registerTool('list_composition_overrides', { description: descriptions['list_composition_overrides'] }, () => withPingReminder((0, listCompositionOverrides_js_1.listCompositionOverrides)(listCompositionOverridesResponse)));
138
143
  server.registerTool('search_component', {
139
144
  description: descriptions['search_component'],
140
145
  inputSchema: { query: zod_1.z.string().describe('Search keyword (Korean/English, case-insensitive)') },
@@ -16,6 +16,11 @@ const getComponentProps = (componentMap, name) => {
16
16
  props: component.props,
17
17
  ...(component.subComponents && { subComponents: component.subComponents }),
18
18
  ...(component.usageExamples && { usageExamples: component.usageExamples }),
19
+ // P8: composition overrides 에서 병합되는 필드들 — AI 가 부모-자식 관계와 권장 조합을 우선 학습
20
+ ...(component.allowedChildren && { allowedChildren: component.allowedChildren }),
21
+ ...(component.allowedParents && { allowedParents: component.allowedParents }),
22
+ ...(component.canonicalExample && { canonicalExample: component.canonicalExample }),
23
+ ...(component.canonicalExamples && { canonicalExamples: component.canonicalExamples }),
19
24
  });
20
25
  };
21
26
  exports.getComponentProps = getComponentProps;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * list_composition_overrides tool — composition.json composition overrides 적용 현황 요약
3
+ *
4
+ * 신규 프로젝트에서 NCDS MCP 와 연결한 뒤, 어느 컴포넌트에 어떤 composition overrides 보강이
5
+ * 들어가 있는지 표 형태(컴포넌트별 row)로 한 번에 확인하는 용도. 응답이 크지 않도록
6
+ * 시나리오 키 목록 + 카운트 위주로 구성하고, 상세는 get_component_props 로 유도.
7
+ *
8
+ * 순수 함수 — server.ts 부팅 시 1회 buildListCompositionOverridesResponse() 로 사전 직렬화.
9
+ */
10
+ import type { ComponentData } from '../types.js';
11
+ import type { CompositionEntry } from '../utils/dataLoader.js';
12
+ import { type McpToolResponse } from '../utils/response.js';
13
+ export interface CompositionOverrideSummary {
14
+ /** 컴포넌트 이름 (kebab-case) */
15
+ component: string;
16
+ /** canonicalExamples 시나리오 key 목록 — 비어있으면 [] */
17
+ canonicalExamples: string[];
18
+ /** legacy 단일 시나리오 canonicalExample 존재 여부 */
19
+ hasCanonicalExample: boolean;
20
+ /** bemClassesExtra 항목 수 */
21
+ bemClassesExtra: number;
22
+ /** allowedChildren 정의된 부모 키 목록 (예: ["Table", "Table.Header", ...]) */
23
+ allowedChildrenKeys: string[];
24
+ /** allowedParents 정의된 자식 키 목록 */
25
+ allowedParentsKeys: string[];
26
+ /** descriptionExtra 존재 여부 */
27
+ descriptionExtra: boolean;
28
+ /** aliasesExtra 항목 수 */
29
+ aliasesExtra: number;
30
+ /**
31
+ * 자기 카테고리 천장 대비 정규화 점수 (0.0 ~ 1.0).
32
+ * 1.0 = 컴포넌트 구조상 가능한 모든 보강 영역을 완료.
33
+ * compound 는 5 영역, non-compound 는 4 영역 (allowedChildren n/a 제외) 기준으로 정규화되어
34
+ * 컴포넌트 종류 무관하게 동일 척도로 비교 가능.
35
+ */
36
+ coverageScore: number;
37
+ /**
38
+ * 컴포넌트 구조상 적용 불가한 보강 영역 목록 (예: non-compound 는 ['allowedChildren']).
39
+ * 비어있으면 모든 영역이 적용 가능 (compound).
40
+ * coverageScore 분모에서 자동 제외되어 천장이 1.0 으로 통일된다.
41
+ */
42
+ notApplicable: string[];
43
+ /** coverageScore 한 줄 해석 — high/medium/low 와 강점 영역 요약 */
44
+ coverageNote: string;
45
+ }
46
+ export interface ListCompositionOverridesResult {
47
+ /** composition overrides 적용 컴포넌트 수 */
48
+ total: number;
49
+ /** 각 척도가 무엇을 의미하고 AI 의 UI 작성 정확도에 어떻게 영향을 주는지 (AI 가 본 응답을 바로 해석하도록 동봉) */
50
+ metrics: Record<string, string>;
51
+ /** coverageScore 산식 (가중치 명시) */
52
+ coverageScoreFormula: string;
53
+ /** 컴포넌트별 요약 row 배열 — coverageScore 내림차순, 동률은 컴포넌트 이름 알파벳 */
54
+ overrides: CompositionOverrideSummary[];
55
+ }
56
+ /** composition overrides map → 요약 결과 (순수 함수). coverageScore 내림차순 + 동률은 이름 알파벳. */
57
+ export declare const buildCompositionOverridesSummary: (compositionOverrides: Record<string, CompositionEntry>, componentMap: Map<string, ComponentData>) => ListCompositionOverridesResult;
58
+ /** server.ts 부팅 시 1회 호출 — 응답 사전 직렬화 */
59
+ export declare const buildListCompositionOverridesResponse: (compositionOverrides: Record<string, CompositionEntry>, componentMap: Map<string, ComponentData>) => McpToolResponse;
60
+ /** list_composition_overrides tool — 사전 직렬화된 응답을 그대로 반환 */
61
+ export declare const listCompositionOverrides: (prebuilt: McpToolResponse) => McpToolResponse;
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listCompositionOverrides = exports.buildListCompositionOverridesResponse = exports.buildCompositionOverridesSummary = void 0;
4
+ const response_js_1 = require("../utils/response.js");
5
+ /**
6
+ * coverageScore 가중치 — 각 보강 영역의 점수 기여도.
7
+ * 시나리오(canonicalExamples)가 가장 큰 가중치를 가지는 이유: AI hallucination 차단력이 가장 큼.
8
+ */
9
+ const COVERAGE_WEIGHTS = {
10
+ CANONICAL_EXAMPLES_FULL: 0.5,
11
+ CANONICAL_EXAMPLES_PARTIAL: 0.25,
12
+ ALLOWED_CHILDREN_FULL: 0.2,
13
+ ALLOWED_CHILDREN_PARTIAL: 0.1,
14
+ BEM_CLASSES_EXTRA: 0.1,
15
+ DESCRIPTION_EXTRA: 0.1,
16
+ ALIASES_EXTRA: 0.1,
17
+ };
18
+ /**
19
+ * 전체 가중치 합 (5 영역 모두 적용 가능한 경우의 최대).
20
+ * applicableMax = TOTAL_WEIGHT - Σ(notApplicable 영역 가중치) 로 동적 계산.
21
+ */
22
+ const TOTAL_WEIGHT = 1.0;
23
+ /**
24
+ * 영역별 가중치 맵 — applicableMax 동적 계산 시 사용.
25
+ * canonicalExamples 는 항상 적용 가능하므로 포함하지 않음 (분모에서 제외되지 않음).
26
+ */
27
+ const AREA_WEIGHT_MAP = {
28
+ allowedChildren: COVERAGE_WEIGHTS.ALLOWED_CHILDREN_FULL,
29
+ bemClassesExtra: COVERAGE_WEIGHTS.BEM_CLASSES_EXTRA,
30
+ descriptionExtra: COVERAGE_WEIGHTS.DESCRIPTION_EXTRA,
31
+ aliasesExtra: COVERAGE_WEIGHTS.ALIASES_EXTRA,
32
+ };
33
+ /** coverageNote tier 임계값. */
34
+ const TIER_THRESHOLD_HIGH = 0.7;
35
+ const TIER_THRESHOLD_MEDIUM = 0.4;
36
+ /** 점수 cap (이론적 최대값). */
37
+ const SCORE_CAP = 1;
38
+ /** 소수점 2자리 반올림용 — `Math.round(x * 100) / 100`. */
39
+ const TWO_DECIMAL_BASE = 100;
40
+ /** AI 가 UI 를 얼마나 정확히 그릴 수 있는지 추정 — 각 composition overrides 보강이 다른 정확도 영역을 커버한다 */
41
+ const METRIC_DESCRIPTIONS = {
42
+ canonicalExamples: 'AI 가 시나리오별로 분기 가능한 정답 트리 수 (form / data-grid / with-tooltip 등). 가장 강한 정확도 신호 — 0 이면 AI 가 prop 조합을 추측해야 한다.',
43
+ hasCanonicalExample: 'legacy 단일 시나리오 존재 여부 — 진입용 표준 트리 1건. canonicalExamples 가 비어있는 컴포넌트의 fallback.',
44
+ bemClassesExtra: 'extract 가 못 잡은 BEM 클래스 화이트리스트 수. validate_html 이 합법/위법을 정확히 판정 — 발명 클래스 사용 차단력.',
45
+ allowedChildrenKeys: 'compound 부모-자식 제약 노드 수. 잘못된 자식 끼우기 / 컨텍스트 위반 (Table.Pagination in DataGrid 등) 차단력.',
46
+ allowedParentsKeys: '역방향 제약 노드 수 — 자식이 어느 부모 안에만 허용되는지 명시.',
47
+ descriptionExtra: 'list_components 응답에 자연어 용도/모드 명시 여부. 같은 컴포넌트가 다른 모드(horizontal/vertical) 로 쓰일 때 AI 가 모드를 인지하는 정확도.',
48
+ aliasesExtra: 'search_component 한국어/유사 키워드 매칭 별칭 수. AI 가 "폼 레이아웃", "세로 테이블" 같은 비공식 용어로 검색해도 정답 컴포넌트에 도달.',
49
+ coverageScore: '자기 카테고리 천장 대비 정규화 점수 (0.0 ~ 1.0). 1.0 이면 컴포넌트 구조상 가능한 모든 보강 영역을 완료. applicableMax = 1.0 − Σ(notApplicable 영역 가중치)로 동적 계산되어 모든 컴포넌트가 동일 척도. 절대 가중합이 아니라 정규화 비율이므로 컴포넌트 종류 무관하게 직접 비교 가능.',
50
+ notApplicable: '컴포넌트 구조상 적용 불가한 보강 영역 목록. non-compound 는 자동으로 ["allowedChildren"] 포함 (자식 없어 부모-자식 제약 의미 없음). composition.json notApplicableAreas 선언(예: extract 가 BEM 을 완전히 잡는 컴포넌트 → ["bemClassesExtra"])과 union 되어 최종 notApplicable 결정. 점수 분모에서 자동 제외되어 천장이 1.0 으로 통일된다.',
51
+ };
52
+ const COVERAGE_FORMULA = 'rawScore (가중 합) = canonicalExamples>=2: +0.50 (>=1 또는 hasCanonicalExample: +0.25) + allowedChildrenKeys>=2: +0.20 (>=1: +0.10, compound 만 적용) + bemClassesExtra>=1: +0.10 + descriptionExtra: +0.10 + aliasesExtra>=1: +0.10. applicableMax = 1.0 − Σ(notApplicable 영역 가중치) — non-compound 는 자동으로 allowedChildren(0.20) 제외, composition.json notApplicableAreas 선언 시 해당 가중치 추가 차감. coverageScore = rawScore / applicableMax (Math.min(1.0, ...)). 임계값: high>=0.70, medium>=0.40, 그 외 low. coverageScore 1.0 = 자기 카테고리에서 더 채울 영역 없음.';
53
+ /** 적용 가능 영역의 절대 가중치 합 (정규화 전 raw 점수). */
54
+ const computeRawScore = (s) => {
55
+ let score = 0;
56
+ if (s.canonicalExamples.length >= 2)
57
+ score += COVERAGE_WEIGHTS.CANONICAL_EXAMPLES_FULL;
58
+ else if (s.canonicalExamples.length >= 1 || s.hasCanonicalExample)
59
+ score += COVERAGE_WEIGHTS.CANONICAL_EXAMPLES_PARTIAL;
60
+ if (s.allowedChildrenKeys.length >= 2)
61
+ score += COVERAGE_WEIGHTS.ALLOWED_CHILDREN_FULL;
62
+ else if (s.allowedChildrenKeys.length >= 1)
63
+ score += COVERAGE_WEIGHTS.ALLOWED_CHILDREN_PARTIAL;
64
+ if (s.bemClassesExtra >= 1)
65
+ score += COVERAGE_WEIGHTS.BEM_CLASSES_EXTRA;
66
+ if (s.descriptionExtra)
67
+ score += COVERAGE_WEIGHTS.DESCRIPTION_EXTRA;
68
+ if (s.aliasesExtra >= 1)
69
+ score += COVERAGE_WEIGHTS.ALIASES_EXTRA;
70
+ return score;
71
+ };
72
+ /**
73
+ * 컴포넌트의 notApplicable 영역 목록.
74
+ * 구조적 제외(non-compound → allowedChildren) + entry 의 notApplicableAreas 선언을 union.
75
+ */
76
+ const getNotApplicable = (isCompound, entryNotApplicableAreas) => {
77
+ const structural = isCompound ? [] : ['allowedChildren'];
78
+ return [...new Set([...structural, ...entryNotApplicableAreas])];
79
+ };
80
+ /**
81
+ * applicableMax = TOTAL_WEIGHT - Σ(notApplicable 영역 가중치).
82
+ * compound 기본: 1.0. non-compound 기본: 0.8 (allowedChildren 0.2 제외).
83
+ * notApplicableAreas 추가 선언 시 해당 가중치만큼 추가 차감 → coverageScore 1.0 = 실제 달성 가능 최대.
84
+ */
85
+ const getApplicableMax = (notApplicable) => {
86
+ const excluded = notApplicable.reduce((sum, area) => sum + (AREA_WEIGHT_MAP[area] ?? 0), 0);
87
+ return Math.round((TOTAL_WEIGHT - excluded) * TWO_DECIMAL_BASE) / TWO_DECIMAL_BASE;
88
+ };
89
+ /** rawScore → coverageScore (자기 카테고리 천장 기준 0~1.0 정규화). */
90
+ const normalizeScore = (rawScore, applicableMax) => Math.min(SCORE_CAP, Math.round((rawScore / applicableMax) * TWO_DECIMAL_BASE) / TWO_DECIMAL_BASE);
91
+ /** coverageScore 를 high/medium/low tier 로 분류. */
92
+ const getTier = (score) => {
93
+ if (score >= TIER_THRESHOLD_HIGH)
94
+ return 'high';
95
+ if (score >= TIER_THRESHOLD_MEDIUM)
96
+ return 'medium';
97
+ return 'low';
98
+ };
99
+ const noteFor = (s, score) => {
100
+ const strengths = [];
101
+ if (s.canonicalExamples.length >= 2)
102
+ strengths.push(`시나리오 ${s.canonicalExamples.length}`);
103
+ else if (s.canonicalExamples.length >= 1 || s.hasCanonicalExample)
104
+ strengths.push('시나리오 1');
105
+ if (s.allowedChildrenKeys.length >= 1)
106
+ strengths.push(`compound 제약 ${s.allowedChildrenKeys.length}`);
107
+ if (s.bemClassesExtra >= 1)
108
+ strengths.push(`BEM 화이트리스트 ${s.bemClassesExtra}`);
109
+ if (s.descriptionExtra)
110
+ strengths.push('모드 인지 보강');
111
+ if (s.aliasesExtra >= 1)
112
+ strengths.push(`검색 별칭 ${s.aliasesExtra}`);
113
+ const tier = getTier(score);
114
+ return strengths.length > 0
115
+ ? `${tier} · ${strengths.join(' + ')}`
116
+ : `${tier} · 보강 거의 없음 — 직접 작성 hallucination 위험`;
117
+ };
118
+ /** 단일 entry 를 row 로 변환 — compound 여부에 따라 applicableMax 가 결정되고 coverageScore 가 정규화된다. */
119
+ const toSummary = (component, entry, componentMap) => {
120
+ const base = {
121
+ component,
122
+ canonicalExamples: Object.keys(entry.canonicalExamples ?? {}).filter((k) => !k.startsWith('_')),
123
+ hasCanonicalExample: !!entry.canonicalExample,
124
+ bemClassesExtra: (entry.bemClassesExtra ?? []).length,
125
+ allowedChildrenKeys: Object.keys(entry.allowedChildren ?? {}),
126
+ allowedParentsKeys: Object.keys(entry.allowedParents ?? {}),
127
+ descriptionExtra: !!entry.descriptionExtra,
128
+ aliasesExtra: (entry.aliasesExtra ?? []).length,
129
+ };
130
+ const subComponentCount = Object.keys(componentMap.get(component)?.subComponents ?? {}).length;
131
+ const isCompound = subComponentCount > 0;
132
+ const notApplicable = getNotApplicable(isCompound, entry.notApplicableAreas ?? []);
133
+ const applicableMax = getApplicableMax(notApplicable);
134
+ const rawScore = computeRawScore(base);
135
+ const coverageScore = normalizeScore(rawScore, applicableMax);
136
+ return { ...base, coverageScore, notApplicable, coverageNote: noteFor(base, coverageScore) };
137
+ };
138
+ /** composition overrides map → 요약 결과 (순수 함수). coverageScore 내림차순 + 동률은 이름 알파벳. */
139
+ const buildCompositionOverridesSummary = (compositionOverrides, componentMap) => {
140
+ const overrides = Object.entries(compositionOverrides)
141
+ .map(([component, entry]) => toSummary(component, entry, componentMap))
142
+ .sort((a, b) => b.coverageScore - a.coverageScore || a.component.localeCompare(b.component));
143
+ return {
144
+ total: overrides.length,
145
+ metrics: METRIC_DESCRIPTIONS,
146
+ coverageScoreFormula: COVERAGE_FORMULA,
147
+ overrides,
148
+ };
149
+ };
150
+ exports.buildCompositionOverridesSummary = buildCompositionOverridesSummary;
151
+ /** server.ts 부팅 시 1회 호출 — 응답 사전 직렬화 */
152
+ const buildListCompositionOverridesResponse = (compositionOverrides, componentMap) => (0, response_js_1.successResponse)((0, exports.buildCompositionOverridesSummary)(compositionOverrides, componentMap));
153
+ exports.buildListCompositionOverridesResponse = buildListCompositionOverridesResponse;
154
+ /** list_composition_overrides tool — 사전 직렬화된 응답을 그대로 반환 */
155
+ const listCompositionOverrides = (prebuilt) => prebuilt;
156
+ exports.listCompositionOverrides = listCompositionOverrides;
@@ -34,5 +34,19 @@ export declare const mergeCdnDefaults: (componentName: string, cdnDefaults: Reco
34
34
  merged: Record<string, unknown>;
35
35
  defaultsApplied: string[];
36
36
  };
37
+ /** jsRequired에 따라 js 필드를 생성 */
38
+ export declare const buildJsField: (componentData: ComponentData, jsApiMap: Map<string, JsApiInfo>) => {
39
+ required: false;
40
+ } | {
41
+ api?: {
42
+ className: string;
43
+ constructor: string;
44
+ constructorParams: Record<string, string>;
45
+ methods: string[];
46
+ example: string;
47
+ } | undefined;
48
+ required: true;
49
+ description: string;
50
+ };
37
51
  /** render_to_html tool — props로 React 컴포넌트를 렌더링하여 HTML + React 매핑 반환 */
38
52
  export declare const renderToHtml: (params: RenderToHtmlParams) => McpToolResponse;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.renderToHtml = exports.mergeCdnDefaults = exports.stripFunctionProps = void 0;
3
+ exports.renderToHtml = exports.buildJsField = exports.mergeCdnDefaults = exports.stripFunctionProps = void 0;
4
4
  /**
5
5
  * render_to_html tool — 컴포넌트 속성을 전달하면 정확한 HTML + React 매핑을 반환 (순수 함수)
6
6
  *
@@ -29,49 +29,106 @@ const MAX_SPEC_DEPTH = 6;
29
29
  const isChildDescriptor = (value) => typeof value === 'object' && value !== null && typeof value.component === 'string';
30
30
  /** PascalCase 변환: "header" → "Header" */
31
31
  const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
32
- /** 단순 컴포넌트 resolve: "button" → { Component, propsSpec } */
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 } */
33
37
  const resolveSimpleComponent = (name, ctx) => {
34
38
  const normalized = (0, response_js_1.normalizeName)(name);
35
39
  const childData = ctx.componentMap.get(normalized);
36
40
  const Component = childData?.exportName ? (ctx.bundle[childData.exportName] ?? null) : null;
37
- return { Component, propsSpec: childData?.props };
41
+ return { Component, propsSpec: childData?.props, compoundKey: null };
38
42
  };
39
- /** compound component resolve: "modal.header" → { Component: bundle.Modal.Header, propsSpec: subComponents["Modal.Header"].props } */
43
+ /** compound component resolve: "data-grid.action-bar" → { Component: bundle.DataGrid.ActionBar, propsSpec: subComponents["DataGrid.ActionBar"].props } */
40
44
  const resolveCompoundComponent = (name, dotIndex, ctx) => {
41
45
  const parentName = name.slice(0, dotIndex).toLowerCase();
42
46
  const subName = name.slice(dotIndex + 1);
43
47
  const parentData = ctx.componentMap.get(parentName);
44
48
  const parentExportName = parentData?.exportName;
45
49
  if (!parentExportName)
46
- return { Component: null, propsSpec: undefined };
50
+ return { Component: null, propsSpec: undefined, compoundKey: null };
47
51
  const Parent = ctx.bundle[parentExportName];
48
- const Component = Parent?.[capitalize(subName)] ?? null;
49
- // subComponents key는 "Modal.Header" 형태 (parent export name + sub name)
50
- const compoundKey = `${parentExportName}.${capitalize(subName)}`;
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}`;
51
57
  const propsSpec = parentData?.subComponents?.[compoundKey]?.props;
52
- return { Component, propsSpec };
58
+ return { Component, propsSpec, compoundKey };
53
59
  };
54
- /** children JSON을 재귀적으로 React element로 변환 */
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 제약 검증 */
55
77
  const resolveChildren = (children, ctx, depth) => {
56
78
  if (depth > MAX_CHILDREN_DEPTH)
57
79
  return null;
58
80
  // 문자열/숫자/boolean — 그대로 반환
59
81
  if (typeof children === 'string' || typeof children === 'number' || typeof children === 'boolean')
60
82
  return children;
61
- // 배열 — 각 요소를 재귀 처리 (MAX_CHILDREN_COUNT 제한)
83
+ // 배열 — 각 요소를 재귀 처리 (MAX_CHILDREN_COUNT 제한, undefined/null 결과 filter)
62
84
  if (Array.isArray(children)) {
63
- return children.slice(0, MAX_CHILDREN_COUNT).map((child) => resolveChildren(child, ctx, depth));
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;
64
94
  }
65
95
  // { component, props?, children? } — React element로 변환
66
96
  if (isChildDescriptor(children)) {
67
97
  const componentName = children.component.trim();
68
98
  const dotIndex = componentName.indexOf('.');
69
- // dot notation: "modal.header" → bundle.Modal.Header (+ subComponents props spec)
70
- const { Component, propsSpec } = dotIndex > 0
99
+ // dot notation: "data-grid.action-bar" → bundle.DataGrid.ActionBar (+ subComponents props spec)
100
+ const resolved = dotIndex > 0
71
101
  ? resolveCompoundComponent(componentName, dotIndex, ctx)
72
102
  : resolveSimpleComponent(componentName, ctx);
73
- if (!Component)
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 로 정식 이름 확인 후 정정.`);
74
130
  return null;
131
+ }
75
132
  // BLOCKED_PROPS(dangerouslySetInnerHTML, ref, __self, __source)를 먼저 제거
76
133
  // propsSpec이 있으면 화이트리스트 필터(sanitizeProps)도 적용하여 XSS/alien prop 차단
77
134
  const rawProps = children.props ?? {};
@@ -80,7 +137,13 @@ const resolveChildren = (children, ctx, depth) => {
80
137
  // descriptor의 children 필드가 있으면 props.children보다 우선
81
138
  const rawChildren = children.children !== undefined ? children.children : childProps.children;
82
139
  if (rawChildren !== undefined) {
83
- childProps.children = resolveChildren(rawChildren, ctx, depth + 1);
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);
84
147
  }
85
148
  const resolvedProps = resolveIconProps(childProps, ctx.iconBundle, propsSpec);
86
149
  return ctx.reactRuntime.createElement(Component, resolvedProps);
@@ -533,7 +596,7 @@ const lookupPropsSpecByComponentName = (componentName, componentMap) => {
533
596
  const parentData = componentMap.get(parentName);
534
597
  if (!parentData?.exportName)
535
598
  return undefined;
536
- const compoundKey = `${parentData.exportName}.${capitalize(subName)}`;
599
+ const compoundKey = `${parentData.exportName}.${kebabToPascal(subName)}`;
537
600
  return parentData.subComponents?.[compoundKey]?.props;
538
601
  }
539
602
  return componentMap.get((0, response_js_1.normalizeName)(componentName))?.props;
@@ -552,7 +615,8 @@ const reactElementToJsx = (node, ctx) => {
552
615
  if (typeof node === 'object' && 'component' in node) {
553
616
  const desc = node;
554
617
  const componentName = desc.component.trim();
555
- const pascalName = componentName.split('.').map(capitalize).join('.');
618
+ // "data-grid.action-bar" "DataGrid.ActionBar" (kebab→PascalCase per dot segment)
619
+ const pascalName = componentName.split('.').map(kebabToPascal).join('.');
556
620
  const propsSpec = lookupPropsSpecByComponentName(componentName, ctx.componentMap);
557
621
  // 이 descriptor에서 사용된 icon 이름 수집 (imports 생성용)
558
622
  if (propsSpec && desc.props) {
@@ -649,6 +713,7 @@ const buildJsField = (componentData, jsApiMap) => {
649
713
  }),
650
714
  };
651
715
  };
716
+ exports.buildJsField = buildJsField;
652
717
  // ── 진입점 ────────────────────────────────────────────────────────
653
718
  /** render_to_html tool — props로 React 컴포넌트를 렌더링하여 HTML + React 매핑 반환 */
654
719
  const renderToHtml = (params) => {
@@ -702,10 +767,45 @@ const renderToHtml = (params) => {
702
767
  ...cdnNormalization.warnings,
703
768
  ];
704
769
  const resolvedProps = resolveIconProps(corrected, iconBundle, componentData.props);
705
- // children 컴포넌트 descriptor React element로 변환
706
- if (resolvedProps.children !== undefined) {
707
- const ctx = { bundle, iconBundle, componentMap, reactRuntime };
708
- resolvedProps.children = resolveChildren(resolvedProps.children, ctx, 0);
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
+ }
709
809
  }
710
810
  // HTML 출력: CDN-input은 init script, 그 외는 React 정적 HTML (renderToStaticMarkup은 필요할 때만 호출)
711
811
  const cdnInitScript = isCdnInput && jsApi?.cdnPattern
@@ -723,6 +823,8 @@ const renderToHtml = (params) => {
723
823
  ? Object.fromEntries(cdnMergeResult.defaultsApplied.map((key) => [key, jsApi.cdnDefaults?.[key]]))
724
824
  : {};
725
825
  const finalDefaultsUsed = { ...hasDefaults, ...cdnDefaultsApplied };
826
+ // Epic 7: prop warnings + children resolve warnings 통합
827
+ const allWarnings = [...warnings, ...renderCtx.warnings];
726
828
  return (0, response_js_1.successResponse)({
727
829
  html,
728
830
  component: normalized,
@@ -730,8 +832,10 @@ const renderToHtml = (params) => {
730
832
  importPath: componentData.importPath,
731
833
  appliedProps: corrected,
732
834
  ...(Object.keys(finalDefaultsUsed).length > 0 && { defaultsUsed: finalDefaultsUsed }),
733
- ...(warnings.length > 0 && { warnings }),
734
- js: buildJsField(componentData, jsApiMap),
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),
735
839
  cdn: cdnMeta ?? undefined,
736
840
  dataVersion: buildDataVersion(cdnMeta, iconMeta),
737
841
  react: buildReactOutput(componentData, corrected, iconMeta, componentMap),
@@ -29,6 +29,9 @@ const validateHtml = (params) => {
29
29
  return (0, response_js_1.successResponse)({ valid: true });
30
30
  if (cdnMeta)
31
31
  errors.push(...(0, bemValidator_js_1.checkCdnInclusion)(trimmed, cdnMeta));
32
+ // P13: Vertical Table context 검증 + 페이지 prefix required 마크 검출
33
+ errors.push(...collectVerticalTableContextErrors(root));
34
+ warnings.push(...collectCustomRequiredMarkWarnings(root));
32
35
  const compliance = buildCompliance({
33
36
  root,
34
37
  componentMap,
@@ -62,6 +65,71 @@ const buildCompliance = (params) => {
62
65
  customSeparation: customResult.customSeparation,
63
66
  });
64
67
  };
68
+ /** P13: Vertical Table context 위반 검출.
69
+ * 1) ncua-table--vertical 안 ncua-table__header-cell → horizontal 전용 오용.
70
+ * 2) ncua-table 안 <tr> 에 ncua-table__row 클래스 누락. */
71
+ const collectVerticalTableContextErrors = (root) => {
72
+ const errors = [];
73
+ // (1) vertical 안의 header-cell 검출
74
+ const verticalRoots = root.querySelectorAll('.ncua-table--vertical');
75
+ for (const v of verticalRoots) {
76
+ const headerCells = v.querySelectorAll('.ncua-table__header-cell');
77
+ for (const _ of headerCells) {
78
+ errors.push({
79
+ type: 'vertical_uses_horizontal_header_cell',
80
+ class: 'ncua-table__header-cell',
81
+ component: 'table',
82
+ message: "Vertical Table 안에서 'ncua-table__header-cell' 사용은 horizontal 전용 — vertical 라벨에는 <th scope='row' class='ncua-table__cell'> 사용 (Table.Cell with isHeader=true).",
83
+ suggestion: "render_to_html('table', { type: 'vertical', children: [...] }) 호출하여 정확한 BEM 출력을 받거나, <th class='ncua-table__header-cell'> 을 <th scope='row' class='ncua-table__cell'> 로 교체.",
84
+ });
85
+ }
86
+ }
87
+ // (2) ncua-table 안 <tr> 의 ncua-table__row 클래스 누락
88
+ const allTables = root.querySelectorAll('.ncua-table');
89
+ for (const t of allTables) {
90
+ const trs = t.querySelectorAll('tr');
91
+ for (const tr of trs) {
92
+ const classAttr = tr.getAttribute('class') ?? '';
93
+ if (!classAttr.split(/\s+/).includes('ncua-table__row')) {
94
+ errors.push({
95
+ type: 'missing_table_row_class',
96
+ class: 'ncua-table__row',
97
+ component: 'table',
98
+ message: "ncua-table 안의 <tr> 에 'ncua-table__row' 클래스 누락. NCDS Table 의 모든 row 는 이 클래스가 필요.",
99
+ suggestion: "render_to_html('table', ...) 출력을 그대로 사용. 수동 작성 시 모든 <tr> 에 class='ncua-table__row' 추가.",
100
+ });
101
+ // 같은 row 중복 보고 방지 — 첫 누락만
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ return errors;
107
+ };
108
+ /** P13: 페이지 prefix 의 *-required 클래스로 필수 마크 자체 작성 검출.
109
+ * 텍스트가 정확히 `*` 한 글자이면 ncua-table__required 사용 권장. */
110
+ const collectCustomRequiredMarkWarnings = (root) => {
111
+ const warnings = [];
112
+ const allWithClass = root.querySelectorAll('[class]');
113
+ for (const el of allWithClass) {
114
+ const classAttr = el.getAttribute('class') ?? '';
115
+ const classes = classAttr.split(/\s+/).filter(Boolean);
116
+ // ncua- 가 아닌 prefix 의 *-required 또는 __required-mark 같은 형태
117
+ const matched = classes.find((c) => !c.startsWith('ncua-') && /(-required(?:-mark)?$|__required(?:-mark)?$)/.test(c));
118
+ if (!matched)
119
+ continue;
120
+ const text = (el.text ?? '').trim();
121
+ if (text !== '*')
122
+ continue;
123
+ warnings.push({
124
+ type: 'custom_required_mark',
125
+ class: matched,
126
+ component: 'table',
127
+ message: `페이지 prefix 클래스 '${matched}' 로 '*' 필수 마크를 자체 작성. NCDS 가 공식 element 'ncua-table__required' 를 제공.`,
128
+ suggestion: `<span class='${matched}'>*</span> 를 <span class='ncua-table__required'>*</span> 로 교체 (Vertical Table 라벨 셀 안에 prepend).`,
129
+ });
130
+ }
131
+ return warnings;
132
+ };
65
133
  /** 최종 응답 빌드 */
66
134
  const buildResponse = (params) => {
67
135
  const { errors, warnings, invalidClassesSet, root, compliance } = params;