@sangheepark/figma-ds-mcp 0.1.2 → 0.2.0
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/dist/index.js +2 -0
- package/dist/tools/data-tools.js +93 -3
- package/dist/tools/mode-tools.js +39 -3
- package/dist/tools/pipeline-tools.d.ts +2 -0
- package/dist/tools/pipeline-tools.js +430 -0
- package/dist/tools/spec-tools.js +2 -1
- package/dist/tools/utility-tools.js +28 -4
- package/dist/ws-bridge.js +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { registerModeTools } from './tools/mode-tools.js';
|
|
|
14
14
|
import { registerVariableTools } from './tools/variable-tools.js';
|
|
15
15
|
import { registerStyleTools } from './tools/style-tools.js';
|
|
16
16
|
import { registerDataTools } from './tools/data-tools.js';
|
|
17
|
+
import { registerPipelineTools } from './tools/pipeline-tools.js';
|
|
17
18
|
// Create the WebSocket bridge to Figma
|
|
18
19
|
const bridge = new FigmaBridge();
|
|
19
20
|
// Create MCP server
|
|
@@ -28,6 +29,7 @@ registerModeTools(server, bridge);
|
|
|
28
29
|
registerVariableTools(server, bridge);
|
|
29
30
|
registerStyleTools(server, bridge);
|
|
30
31
|
registerDataTools(server, bridge);
|
|
32
|
+
registerPipelineTools(server);
|
|
31
33
|
// Connect to Claude Code via stdio
|
|
32
34
|
async function main() {
|
|
33
35
|
const transport = new StdioServerTransport();
|
package/dist/tools/data-tools.js
CHANGED
|
@@ -1,9 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Data management tools: fetch_component_sets, export_style_guide, export_icons
|
|
2
|
+
* Data management tools: search_components, fetch_component_sets, export_style_guide, export_icons
|
|
3
3
|
* Phase 4 — Design system data update automation
|
|
4
4
|
*/
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
6
8
|
export function registerDataTools(server, bridge) {
|
|
9
|
+
// search_components — Search local component-sets data by name/keyword (no bridge needed)
|
|
10
|
+
server.tool('search_components', 'Search component sets by name or keyword in local blueprint data. No Figma connection needed. Returns matching components with key, variants, properties, and contains. Use this during code analysis to check if a library component exists before writing specs.', {
|
|
11
|
+
query: z.string().describe('Component name or keyword to search (case-insensitive partial match)'),
|
|
12
|
+
library: z.string().optional().describe('Library name (e.g., "chakra-ui", "web-ui"). Omit to search all libraries.'),
|
|
13
|
+
}, async ({ query, library }) => {
|
|
14
|
+
try {
|
|
15
|
+
const dataDir = join(process.cwd(), '.claude', 'blueprint', 'data');
|
|
16
|
+
let files;
|
|
17
|
+
if (library) {
|
|
18
|
+
files = [`${library}-component-sets.json`];
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
files = readdirSync(dataDir).filter(f => f.endsWith('-component-sets.json'));
|
|
22
|
+
}
|
|
23
|
+
const results = [];
|
|
24
|
+
const queryLower = query.toLowerCase();
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const libName = file.replace('-component-sets.json', '');
|
|
27
|
+
const filePath = join(dataDir, file);
|
|
28
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
29
|
+
for (const [name, entry] of Object.entries(data)) {
|
|
30
|
+
if (name.toLowerCase().includes(queryLower)) {
|
|
31
|
+
const e = entry;
|
|
32
|
+
const result = {
|
|
33
|
+
name,
|
|
34
|
+
library: libName,
|
|
35
|
+
key: e.key,
|
|
36
|
+
};
|
|
37
|
+
if (e.variants)
|
|
38
|
+
result.variants = e.variants;
|
|
39
|
+
if (e.properties)
|
|
40
|
+
result.properties = e.properties;
|
|
41
|
+
if (e.contains)
|
|
42
|
+
result.contains = e.contains;
|
|
43
|
+
results.push(result);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
content: [{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: JSON.stringify({
|
|
51
|
+
success: true,
|
|
52
|
+
query,
|
|
53
|
+
library: library || 'all',
|
|
54
|
+
matchCount: results.length,
|
|
55
|
+
results,
|
|
56
|
+
}, null, 2),
|
|
57
|
+
}],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
62
|
+
return {
|
|
63
|
+
content: [{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify({ success: false, error: msg }, null, 2),
|
|
66
|
+
}],
|
|
67
|
+
isError: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// inspect_component — Inspect a library ComponentSet structure via Plugin API (requires bridge)
|
|
72
|
+
server.tool('inspect_component', 'Inspect a library ComponentSet by its key. Returns variant axes (names, options, defaults), component properties (text, boolean, instance-swap), variant count, and default variant. Requires the Bridge plugin to be connected. Use this after search_components to understand the exact structure before writing specs.', {
|
|
73
|
+
key: z.string().describe('The library component key (from search_components or preset data)'),
|
|
74
|
+
}, async ({ key }) => {
|
|
75
|
+
try {
|
|
76
|
+
const result = await bridge.send('bridge-inspect-component', { key });
|
|
77
|
+
return {
|
|
78
|
+
content: [{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: JSON.stringify(result, null, 2),
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
86
|
+
return {
|
|
87
|
+
content: [{
|
|
88
|
+
type: 'text',
|
|
89
|
+
text: JSON.stringify({ success: false, error: msg }, null, 2),
|
|
90
|
+
}],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
});
|
|
7
95
|
// fetch_component_sets — Fetch component sets from Figma REST API (server-side, no bridge)
|
|
8
96
|
server.tool('fetch_component_sets', 'Fetch component sets from a Figma library file via REST API. Requires a Figma Personal Access Token (PAT) and file key. Returns component set names, keys, and node IDs.', {
|
|
9
97
|
fileKey: z.string().describe('Figma file key (from URL: figma.com/design/{fileKey}/...)'),
|
|
@@ -45,9 +133,11 @@ export function registerDataTools(server, bridge) {
|
|
|
45
133
|
}
|
|
46
134
|
});
|
|
47
135
|
// export_style_guide — Export styles, variables, and components from the current Figma file
|
|
48
|
-
server.tool('export_style_guide', 'Export the style guide (variables, styles, components) from the currently open Figma file. Returns both JSON (for import/preset) and Markdown (for AI context). Requires the Bridge plugin to be connected.
|
|
136
|
+
server.tool('export_style_guide', 'Export the style guide (variables, styles, components) from the currently open Figma file. Returns both JSON (for import/preset) and Markdown (for AI context). Requires the Bridge plugin to be connected. Use "section" to export only a specific part (fill-styles, text-styles, effect-styles, variables, components, library-components).', {
|
|
137
|
+
section: z.enum(['fill-styles', 'text-styles', 'effect-styles', 'variables', 'components', 'library-components']).optional().describe('Optional: export only a specific section of the style guide'),
|
|
138
|
+
}, async ({ section }) => {
|
|
49
139
|
try {
|
|
50
|
-
const result = await bridge.send('bridge-export-style-guide', {});
|
|
140
|
+
const result = await bridge.send('bridge-export-style-guide', { section });
|
|
51
141
|
return {
|
|
52
142
|
content: [{
|
|
53
143
|
type: 'text',
|
package/dist/tools/mode-tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mode tools: set_style_mode, load_preset, get_presets
|
|
2
|
+
* Mode tools: set_style_mode, load_preset, set_active_presets, get_presets
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export function registerModeTools(server, bridge) {
|
|
@@ -37,8 +37,9 @@ After setting "library" mode, you should also call load_preset to load a library
|
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
39
|
// load_preset — Load a library preset (required for library mode)
|
|
40
|
-
server.tool('load_preset', `Load a library preset by ID. Use get_presets first to see available presets.
|
|
41
|
-
Loading a preset provides variable names, style names, and library component keys for use in specs
|
|
40
|
+
server.tool('load_preset', `Load a single library preset by ID. Use get_presets first to see available presets.
|
|
41
|
+
Loading a preset provides variable names, style names, and library component keys for use in specs.
|
|
42
|
+
WARNING: This replaces any previously active presets. For multi-library projects, use set_active_presets instead.`, {
|
|
42
43
|
presetId: z.string().describe('Preset ID from get_presets list'),
|
|
43
44
|
}, async ({ presetId }) => {
|
|
44
45
|
try {
|
|
@@ -68,6 +69,41 @@ Loading a preset provides variable names, style names, and library component key
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
});
|
|
72
|
+
// set_active_presets — Set multiple active presets for multi-library projects
|
|
73
|
+
server.tool('set_active_presets', `Set multiple library presets as active simultaneously. Use this when a project requires components, styles, or variables from multiple libraries (e.g., text styles from web-ui + components from chakra-ui).
|
|
74
|
+
|
|
75
|
+
The primary preset takes priority for overlapping keys. Secondary presets fill in missing keys.
|
|
76
|
+
Use get_presets first to see available preset IDs.`, {
|
|
77
|
+
presetIds: z.array(z.string()).describe('Array of preset IDs to activate'),
|
|
78
|
+
primaryId: z.string().optional().describe('Primary preset ID (takes priority for overlapping keys). Defaults to first in array.'),
|
|
79
|
+
}, async ({ presetIds, primaryId }) => {
|
|
80
|
+
try {
|
|
81
|
+
const result = await bridge.send('bridge-set-active-presets', {
|
|
82
|
+
presetIds,
|
|
83
|
+
primaryId: primaryId || null,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify({
|
|
89
|
+
success: true,
|
|
90
|
+
presets: result.presets || [],
|
|
91
|
+
primaryId: result.primaryId || null,
|
|
92
|
+
}, null, 2),
|
|
93
|
+
}],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
98
|
+
return {
|
|
99
|
+
content: [{
|
|
100
|
+
type: 'text',
|
|
101
|
+
text: JSON.stringify({ success: false, error: msg }, null, 2),
|
|
102
|
+
}],
|
|
103
|
+
isError: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
});
|
|
71
107
|
// get_presets — List available library presets
|
|
72
108
|
server.tool('get_presets', 'List all available library presets bundled in the plugin. Returns preset IDs, names, and descriptions.', {}, async () => {
|
|
73
109
|
try {
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline tools: validate_traversal, enrich_spec, spec_to_tree, gate_check
|
|
3
|
+
* Phase 2 결정적 파이프라인 도구. Figma 연결 불필요 (순수 JSON 변환).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { dirname } from 'path';
|
|
8
|
+
// Layout 허용 key 목록
|
|
9
|
+
// 주의: schema.ts (src/plugin/parser/schema.ts)의 layoutSchema와 동기화 필요.
|
|
10
|
+
// 별도 패키지이므로 import 불가 — schema.ts에 key 추가 시 여기도 수동 업데이트.
|
|
11
|
+
const LAYOUT_KEYS = new Set([
|
|
12
|
+
'direction', 'gap', 'padding', 'align-items', 'justify-content',
|
|
13
|
+
'width', 'height', 'positioning', 'x', 'y', 'wrap', 'wrap-gap',
|
|
14
|
+
'z-order', 'clip-content', 'rotation',
|
|
15
|
+
'min-width', 'max-width', 'min-height', 'max-height',
|
|
16
|
+
]);
|
|
17
|
+
// emoji 감지 정규식 (surrogate pairs + common symbols)
|
|
18
|
+
const EMOJI_PATTERN = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}▶♡►◄●○■□★☆♪♫✓✗←→↑↓]/u;
|
|
19
|
+
// CSS font-weight name → number
|
|
20
|
+
const FONT_WEIGHT_MAP = {
|
|
21
|
+
thin: '100', hairline: '100',
|
|
22
|
+
extralight: '200', ultralight: '200',
|
|
23
|
+
light: '300',
|
|
24
|
+
normal: '400', regular: '400',
|
|
25
|
+
medium: '500',
|
|
26
|
+
semibold: '600', demibold: '600',
|
|
27
|
+
bold: '700',
|
|
28
|
+
extrabold: '800', ultrabold: '800',
|
|
29
|
+
black: '900', heavy: '900',
|
|
30
|
+
};
|
|
31
|
+
// --- B2.5: validate_traversal ---
|
|
32
|
+
function validateTraversal(traversal) {
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const errors = [];
|
|
35
|
+
function walk(node, path) {
|
|
36
|
+
// type 필수
|
|
37
|
+
if (!node.type) {
|
|
38
|
+
errors.push(`${path}: missing "type" field`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// instance는 _ds 필수
|
|
42
|
+
if (node.type === 'instance' && !node._ds) {
|
|
43
|
+
errors.push(`${path}: instance node missing "_ds" field`);
|
|
44
|
+
}
|
|
45
|
+
// instance에 library-key가 직접 포함되면 안 됨
|
|
46
|
+
if (node['library-key']) {
|
|
47
|
+
warnings.push(`${path}: "library-key" should not be in traversal output — use "_ds" instead`);
|
|
48
|
+
}
|
|
49
|
+
// text 노드 emoji 감지
|
|
50
|
+
if (node.type === 'text' && node.content && EMOJI_PATTERN.test(node.content)) {
|
|
51
|
+
warnings.push(`${path}: text content contains emoji pattern "${node.content.slice(0, 20)}" — should this be an instance with _ds?`);
|
|
52
|
+
}
|
|
53
|
+
// layout key 검증
|
|
54
|
+
if (node.layout) {
|
|
55
|
+
for (const key of Object.keys(node.layout)) {
|
|
56
|
+
if (!LAYOUT_KEYS.has(key)) {
|
|
57
|
+
warnings.push(`${path}: layout contains non-layout key "${key}" — should be in style`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// children 재귀
|
|
62
|
+
if (node.children) {
|
|
63
|
+
node.children.forEach((child, i) => {
|
|
64
|
+
walk(child, `${path}.children[${i}]`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
walk(traversal, 'root');
|
|
69
|
+
return { warnings, errors };
|
|
70
|
+
}
|
|
71
|
+
// --- B3: enrich_spec ---
|
|
72
|
+
function enrichSpec(traversal, mapping) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
function deepClone(obj) {
|
|
75
|
+
return JSON.parse(JSON.stringify(obj));
|
|
76
|
+
}
|
|
77
|
+
const spec = deepClone(traversal);
|
|
78
|
+
function walk(node, path) {
|
|
79
|
+
// Step 0: 속성 위치 교정 (layout에 있으면 안 되는 key → style로 이동)
|
|
80
|
+
if (node.layout) {
|
|
81
|
+
const keysToMove = [];
|
|
82
|
+
for (const key of Object.keys(node.layout)) {
|
|
83
|
+
if (!LAYOUT_KEYS.has(key)) {
|
|
84
|
+
keysToMove.push(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (keysToMove.length > 0) {
|
|
88
|
+
node.style = node.style || {};
|
|
89
|
+
for (const key of keysToMove) {
|
|
90
|
+
node.style[key] = node.layout[key];
|
|
91
|
+
delete node.layout[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Step 1: _ds → library-key (D9: components에서 통합 검색, 아이콘도 포함)
|
|
96
|
+
if (node._ds) {
|
|
97
|
+
const dsName = node._ds;
|
|
98
|
+
const component = mapping.components?.[dsName];
|
|
99
|
+
if (component) {
|
|
100
|
+
node['library-key'] = component['library-key'];
|
|
101
|
+
// Step 2: _ds_props → variant-props + overrides 분리
|
|
102
|
+
if (node._ds_props) {
|
|
103
|
+
const variantProps = {};
|
|
104
|
+
const overrides = {};
|
|
105
|
+
for (const [key, value] of Object.entries(node._ds_props)) {
|
|
106
|
+
if (component.variants && key in component.variants) {
|
|
107
|
+
// variant axis에 해당하는 prop
|
|
108
|
+
if (typeof value === 'string' && component.defaults?.[key] !== value) {
|
|
109
|
+
variantProps[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// variant가 아닌 prop → overrides (text, boolean, swap)
|
|
114
|
+
overrides[key] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(variantProps).length > 0) {
|
|
118
|
+
node['variant-props'] = variantProps;
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(overrides).length > 0) {
|
|
121
|
+
node['overrides'] = overrides;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// _overrides → overrides에 병합
|
|
125
|
+
if (node._overrides) {
|
|
126
|
+
const existing = node['overrides'];
|
|
127
|
+
node['overrides'] = { ...existing, ...node._overrides };
|
|
128
|
+
delete node._overrides;
|
|
129
|
+
}
|
|
130
|
+
delete node._ds;
|
|
131
|
+
delete node._ds_props;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
errors.push(`${path}: _ds "${dsName}" not found in mapping components`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Step 3: 색상 토큰 치환 (style의 HEX → {variable})
|
|
138
|
+
if (node.style && mapping.tokens) {
|
|
139
|
+
for (const [key, value] of Object.entries(node.style)) {
|
|
140
|
+
if (typeof value === 'string' && value.startsWith('#')) {
|
|
141
|
+
const token = mapping.tokens[value.toUpperCase()] || mapping.tokens[value];
|
|
142
|
+
if (token) {
|
|
143
|
+
node.style[key] = token;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Step 4: text-style 치환
|
|
149
|
+
if (node.type === 'text' && node.style && mapping.textStyles) {
|
|
150
|
+
const fontSize = node.style['font-size'];
|
|
151
|
+
let fontWeight = node.style['font-weight'];
|
|
152
|
+
// font-weight: number → string, name → number
|
|
153
|
+
if (typeof fontWeight === 'number')
|
|
154
|
+
fontWeight = String(fontWeight);
|
|
155
|
+
if (typeof fontWeight === 'string') {
|
|
156
|
+
const normalized = FONT_WEIGHT_MAP[fontWeight.toLowerCase()];
|
|
157
|
+
if (normalized)
|
|
158
|
+
fontWeight = normalized;
|
|
159
|
+
}
|
|
160
|
+
if (fontSize && fontWeight) {
|
|
161
|
+
const key = `${fontSize}/${fontWeight}`;
|
|
162
|
+
const textStyle = mapping.textStyles[key];
|
|
163
|
+
if (textStyle) {
|
|
164
|
+
node.style['text-style'] = textStyle;
|
|
165
|
+
delete node.style['font-size'];
|
|
166
|
+
delete node.style['font-weight'];
|
|
167
|
+
delete node.style['font-family'];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Step 5: 규칙 적용
|
|
172
|
+
if (node.style) {
|
|
173
|
+
// font-weight → string (number→string + name→number)
|
|
174
|
+
const fw = node.style['font-weight'];
|
|
175
|
+
if (typeof fw === 'number') {
|
|
176
|
+
node.style['font-weight'] = String(fw);
|
|
177
|
+
}
|
|
178
|
+
else if (typeof fw === 'string') {
|
|
179
|
+
const normalized = FONT_WEIGHT_MAP[fw.toLowerCase()];
|
|
180
|
+
if (normalized) {
|
|
181
|
+
node.style['font-weight'] = normalized;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// line-height 정규화: 숫자 배수 → 퍼센트
|
|
185
|
+
const lh = node.style['line-height'];
|
|
186
|
+
if (typeof lh === 'number') {
|
|
187
|
+
node.style['line-height'] = `${Math.round(lh * 100)}%`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// children 재귀
|
|
191
|
+
if (node.children) {
|
|
192
|
+
node.children.forEach((child, i) => {
|
|
193
|
+
walk(child, `${path}.children[${i}]`);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
walk(spec, 'root');
|
|
198
|
+
return { spec, errors };
|
|
199
|
+
}
|
|
200
|
+
// --- B4: spec_to_tree ---
|
|
201
|
+
function specToTree(node, depth = 0, isLast = true) {
|
|
202
|
+
const lines = [];
|
|
203
|
+
// prefix 생성
|
|
204
|
+
function getPrefix(d, last) {
|
|
205
|
+
if (d === 0)
|
|
206
|
+
return '';
|
|
207
|
+
const parts = [];
|
|
208
|
+
for (let i = 1; i < d; i++) {
|
|
209
|
+
parts.push('│ ');
|
|
210
|
+
}
|
|
211
|
+
parts.push(last ? '└── ' : '├── ');
|
|
212
|
+
return parts.join('');
|
|
213
|
+
}
|
|
214
|
+
const prefix = getPrefix(depth, isLast);
|
|
215
|
+
const nodeAny = node;
|
|
216
|
+
switch (node.type) {
|
|
217
|
+
case 'frame':
|
|
218
|
+
case 'component': {
|
|
219
|
+
const parts = [];
|
|
220
|
+
if (node.layout?.direction)
|
|
221
|
+
parts.push(node.layout.direction);
|
|
222
|
+
if (node.layout?.width)
|
|
223
|
+
parts.push(`w:${node.layout.width}`);
|
|
224
|
+
if (node.layout?.height)
|
|
225
|
+
parts.push(`h:${node.layout.height}`);
|
|
226
|
+
const layoutStr = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
227
|
+
lines.push(`${prefix}${node.name || '(unnamed)'}${layoutStr}`);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'instance': {
|
|
231
|
+
const key = nodeAny['library-key'];
|
|
232
|
+
const ds = node._ds;
|
|
233
|
+
if (key) {
|
|
234
|
+
const name = node.name || key.slice(0, 8) + '...';
|
|
235
|
+
lines.push(`${prefix}library: ${name}`);
|
|
236
|
+
}
|
|
237
|
+
else if (ds) {
|
|
238
|
+
lines.push(`${prefix}_ds: ${ds} (미해결)`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
lines.push(`${prefix}instance: (unknown)`);
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'text': {
|
|
246
|
+
const content = node.content || '';
|
|
247
|
+
const truncated = content.length > 20 ? content.slice(0, 20) + '...' : content;
|
|
248
|
+
lines.push(`${prefix}text: "${truncated}"`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
lines.push(`${prefix}${node.name || node.type}`);
|
|
253
|
+
}
|
|
254
|
+
// children 재귀
|
|
255
|
+
if (node.children) {
|
|
256
|
+
node.children.forEach((child, i) => {
|
|
257
|
+
lines.push(specToTree(child, depth + 1, i === node.children.length - 1));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
// --- B5: gate_check ---
|
|
263
|
+
function gateCheck(spec) {
|
|
264
|
+
const results = [];
|
|
265
|
+
const failures = [];
|
|
266
|
+
// G1: placeholder 미포함
|
|
267
|
+
const placeholderIssues = [];
|
|
268
|
+
function checkPlaceholders(node, path) {
|
|
269
|
+
// text content에서 placeholder 감지
|
|
270
|
+
if (node.content) {
|
|
271
|
+
if (EMOJI_PATTERN.test(node.content)) {
|
|
272
|
+
placeholderIssues.push(`${path}: emoji in text "${node.content.slice(0, 20)}"`);
|
|
273
|
+
}
|
|
274
|
+
if (/TBD|TODO|NOT FOUND|validate first|\?\?/i.test(node.content)) {
|
|
275
|
+
placeholderIssues.push(`${path}: placeholder text "${node.content.slice(0, 30)}"`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// _ds가 남아있으면 미해결
|
|
279
|
+
if (node._ds) {
|
|
280
|
+
placeholderIssues.push(`${path}: unresolved _ds "${node._ds}"`);
|
|
281
|
+
}
|
|
282
|
+
node.children?.forEach((child, i) => checkPlaceholders(child, `${path}[${i}]`));
|
|
283
|
+
}
|
|
284
|
+
checkPlaceholders(spec, 'root');
|
|
285
|
+
results.push({
|
|
286
|
+
gate: 'G1: placeholder 미포함',
|
|
287
|
+
pass: placeholderIssues.length === 0,
|
|
288
|
+
details: placeholderIssues.length > 0 ? placeholderIssues.join('; ') : undefined,
|
|
289
|
+
});
|
|
290
|
+
// G2: library-key 40자 해시
|
|
291
|
+
const keyIssues = [];
|
|
292
|
+
function checkKeys(node, path) {
|
|
293
|
+
const nodeAny = node;
|
|
294
|
+
if (nodeAny['library-key']) {
|
|
295
|
+
const key = nodeAny['library-key'];
|
|
296
|
+
if (!/^[a-f0-9]{40,}$/.test(key)) {
|
|
297
|
+
keyIssues.push(`${path}: invalid library-key "${key.slice(0, 20)}..."`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
node.children?.forEach((child, i) => checkKeys(child, `${path}[${i}]`));
|
|
301
|
+
}
|
|
302
|
+
checkKeys(spec, 'root');
|
|
303
|
+
results.push({
|
|
304
|
+
gate: 'G2: library-key 40자 해시',
|
|
305
|
+
pass: keyIssues.length === 0,
|
|
306
|
+
details: keyIssues.length > 0 ? keyIssues.join('; ') : undefined,
|
|
307
|
+
});
|
|
308
|
+
// G3: 제거됨 — 단일 Component는 variant 없을 수 있어 코드로 pass/fail 판단 불가.
|
|
309
|
+
// ComponentSet 여부는 mapping 데이터에 의존하므로 gate_check 단독으로 검증 불가.
|
|
310
|
+
// enrichment Step 2에서 variant-props 변환 시 mapping.variants 존재 여부로 이미 처리.
|
|
311
|
+
// G4: 컨테이너에 direction 명시
|
|
312
|
+
const directionIssues = [];
|
|
313
|
+
function checkDirection(node, path) {
|
|
314
|
+
if ((node.type === 'frame' || node.type === 'component') && node.children && node.children.length > 0) {
|
|
315
|
+
if (!node.layout?.direction) {
|
|
316
|
+
directionIssues.push(`${path}: "${node.name || '(unnamed)'}" has children but no direction`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
node.children?.forEach((child, i) => checkDirection(child, `${path}[${i}]`));
|
|
320
|
+
}
|
|
321
|
+
checkDirection(spec, 'root');
|
|
322
|
+
results.push({
|
|
323
|
+
gate: 'G4: 컨테이너 direction 명시',
|
|
324
|
+
pass: directionIssues.length === 0,
|
|
325
|
+
details: directionIssues.length > 0 ? directionIssues.join('; ') : undefined,
|
|
326
|
+
});
|
|
327
|
+
// G5: layout warning 체크 (analyzeLayoutWarnings 로직 재사용)
|
|
328
|
+
const layoutWarnings = [];
|
|
329
|
+
function checkFillChain(node, ancestors) {
|
|
330
|
+
for (const axis of ['width', 'height']) {
|
|
331
|
+
const size = node.layout?.[axis];
|
|
332
|
+
if (size === 'fill') {
|
|
333
|
+
for (const ancestor of ancestors) {
|
|
334
|
+
const ancestorSize = ancestor.layout?.[axis];
|
|
335
|
+
if (!ancestorSize || ancestorSize === 'hug') {
|
|
336
|
+
layoutWarnings.push(`"${node.name || '(unnamed)'}": ${axis}=fill but ancestor "${ancestor.name || '(unnamed)'}" has no explicit ${axis}`);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
node.children?.forEach(child => checkFillChain(child, [...ancestors, node]));
|
|
343
|
+
}
|
|
344
|
+
checkFillChain(spec, []);
|
|
345
|
+
results.push({
|
|
346
|
+
gate: 'G5: layout warning 0건',
|
|
347
|
+
pass: layoutWarnings.length === 0,
|
|
348
|
+
details: layoutWarnings.length > 0 ? layoutWarnings.join('; ') : undefined,
|
|
349
|
+
});
|
|
350
|
+
const pass = results.every(r => r.pass);
|
|
351
|
+
return { pass, results };
|
|
352
|
+
}
|
|
353
|
+
// --- Tool Registration ---
|
|
354
|
+
export function registerPipelineTools(server) {
|
|
355
|
+
// validate_traversal — B2.5
|
|
356
|
+
server.tool('validate_traversal', 'Validate traversal JSON output structure. Checks: type field presence, _ds on instances, emoji in text, layout key validation. Run after code-traversal, before enrich_spec.', {
|
|
357
|
+
traversal: z.record(z.unknown()).describe('Traversal JSON root node'),
|
|
358
|
+
}, async ({ traversal }) => {
|
|
359
|
+
const result = validateTraversal(traversal);
|
|
360
|
+
return {
|
|
361
|
+
content: [{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: JSON.stringify({
|
|
364
|
+
valid: result.errors.length === 0,
|
|
365
|
+
errors: result.errors,
|
|
366
|
+
warnings: result.warnings,
|
|
367
|
+
}, null, 2),
|
|
368
|
+
}],
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
// enrich_spec — B3
|
|
372
|
+
server.tool('enrich_spec', `Enrich traversal JSON with mapping data to produce a build-ready spec.
|
|
373
|
+
6 steps: (0) layout→style key correction, (1) _ds→library-key, (2) _ds_props→variant-props+overrides, (3) HEX→token, (4) text-style, (5) format rules.
|
|
374
|
+
Run after validate_traversal. If outputPath is provided, spec is saved to file and not included in response (saves context).`, {
|
|
375
|
+
traversal: z.record(z.unknown()).describe('Validated traversal JSON root node'),
|
|
376
|
+
mapping: z.record(z.unknown()).describe('mapping.json data (components, tokens, textStyles)'),
|
|
377
|
+
outputPath: z.string().optional().describe('File path to save spec.json. If provided, spec is not returned in response.'),
|
|
378
|
+
}, async ({ traversal, mapping, outputPath }) => {
|
|
379
|
+
const result = enrichSpec(traversal, mapping);
|
|
380
|
+
if (outputPath && result.errors.length === 0) {
|
|
381
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
382
|
+
writeFileSync(outputPath, JSON.stringify(result.spec, null, 2));
|
|
383
|
+
return {
|
|
384
|
+
content: [{
|
|
385
|
+
type: 'text',
|
|
386
|
+
text: JSON.stringify({
|
|
387
|
+
success: true,
|
|
388
|
+
savedTo: outputPath,
|
|
389
|
+
errors: [],
|
|
390
|
+
}, null, 2),
|
|
391
|
+
}],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
content: [{
|
|
396
|
+
type: 'text',
|
|
397
|
+
text: JSON.stringify({
|
|
398
|
+
success: result.errors.length === 0,
|
|
399
|
+
spec: result.spec,
|
|
400
|
+
errors: result.errors,
|
|
401
|
+
}, null, 2),
|
|
402
|
+
}],
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
// spec_to_tree — B4
|
|
406
|
+
server.tool('spec_to_tree', 'Convert a spec JSON to ASCII tree view for human review. Frames show layout, instances show component names, text shows content.', {
|
|
407
|
+
spec: z.record(z.unknown()).describe('Spec JSON root node'),
|
|
408
|
+
}, async ({ spec }) => {
|
|
409
|
+
const tree = specToTree(spec);
|
|
410
|
+
return {
|
|
411
|
+
content: [{
|
|
412
|
+
type: 'text',
|
|
413
|
+
text: tree,
|
|
414
|
+
}],
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
// gate_check — B5
|
|
418
|
+
server.tool('gate_check', `Run Gate checks on a spec before building. Checks: G1(no placeholders/emoji), G2(library-key 40-char hex), G4(container direction), G5(layout warnings). Returns pass/fail with details.`, {
|
|
419
|
+
spec: z.record(z.unknown()).describe('Spec JSON root node to validate'),
|
|
420
|
+
}, async ({ spec }) => {
|
|
421
|
+
const result = gateCheck(spec);
|
|
422
|
+
return {
|
|
423
|
+
content: [{
|
|
424
|
+
type: 'text',
|
|
425
|
+
text: JSON.stringify(result, null, 2),
|
|
426
|
+
}],
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
//# sourceMappingURL=pipeline-tools.js.map
|
package/dist/tools/spec-tools.js
CHANGED
|
@@ -35,7 +35,8 @@ const styleSchema = z.object({
|
|
|
35
35
|
opacity: z.number().optional(),
|
|
36
36
|
'font-family': z.string().optional(),
|
|
37
37
|
'font-size': z.string().optional(),
|
|
38
|
-
'font-weight': z.string().optional()
|
|
38
|
+
'font-weight': z.union([z.string(), z.number()]).optional()
|
|
39
|
+
.transform(v => v != null && typeof v === 'number' ? String(v) : v),
|
|
39
40
|
color: z.string().optional(),
|
|
40
41
|
'text-align': z.enum(['left', 'center', 'right', 'justify']).optional(),
|
|
41
42
|
'line-height': z.string().optional(),
|
|
@@ -79,7 +79,7 @@ export function registerUtilityTools(server, bridge) {
|
|
|
79
79
|
try {
|
|
80
80
|
// Pre-create layout analysis (runs locally, no Figma needed)
|
|
81
81
|
const layoutWarnings = analyzeLayoutWarnings(spec);
|
|
82
|
-
// Plugin-side validation (
|
|
82
|
+
// Plugin-side validation (structure only — zod schema)
|
|
83
83
|
const result = await bridge.send('bridge-validate-spec', { spec });
|
|
84
84
|
const response = {
|
|
85
85
|
valid: true,
|
|
@@ -89,9 +89,6 @@ export function registerUtilityTools(server, bridge) {
|
|
|
89
89
|
if (layoutWarnings.length > 0) {
|
|
90
90
|
response.layoutWarnings = layoutWarnings;
|
|
91
91
|
}
|
|
92
|
-
if (result.semanticWarnings && result.semanticWarnings.length > 0) {
|
|
93
|
-
response.semanticWarnings = result.semanticWarnings;
|
|
94
|
-
}
|
|
95
92
|
return {
|
|
96
93
|
content: [{
|
|
97
94
|
type: 'text',
|
|
@@ -137,6 +134,33 @@ export function registerUtilityTools(server, bridge) {
|
|
|
137
134
|
};
|
|
138
135
|
}
|
|
139
136
|
});
|
|
137
|
+
// diagnose_variables — Diagnose variable import capability
|
|
138
|
+
server.tool('diagnose_variables', 'Diagnose variable import capability in the current Figma file. Tests: local variables, teamLibrary access, library variable collections, and optionally imports a single variable by key to test.', {
|
|
139
|
+
variableKey: z.string().optional().describe('Optional: a specific variable key to test importing'),
|
|
140
|
+
}, async ({ variableKey }) => {
|
|
141
|
+
try {
|
|
142
|
+
const params = {};
|
|
143
|
+
if (variableKey)
|
|
144
|
+
params.variableKey = variableKey;
|
|
145
|
+
const result = await bridge.send('bridge-diagnose-variables', params);
|
|
146
|
+
return {
|
|
147
|
+
content: [{
|
|
148
|
+
type: 'text',
|
|
149
|
+
text: JSON.stringify(result, null, 2),
|
|
150
|
+
}],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
155
|
+
return {
|
|
156
|
+
content: [{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text: JSON.stringify({ success: false, error: msg }, null, 2),
|
|
159
|
+
}],
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
});
|
|
140
164
|
// generate_from_yaml — Legacy YAML passthrough for compatibility
|
|
141
165
|
server.tool('generate_from_yaml', 'Generate Figma components from YAML text (legacy passthrough). Prefer using create_from_spec with JSON instead.', {
|
|
142
166
|
yaml: z.string().describe('YAML spec text'),
|
package/dist/ws-bridge.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Provides request/response correlation via requestId.
|
|
4
4
|
*/
|
|
5
5
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
|
-
const WS_PORT = 3055;
|
|
6
|
+
const WS_PORT = parseInt(process.env.FIGMA_DS_PORT || '3055', 10);
|
|
7
7
|
const REQUEST_TIMEOUT = 180_000; // 180 seconds (font/library loading can be very slow)
|
|
8
8
|
export class FigmaBridge {
|
|
9
9
|
wss;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sangheepark/figma-ds-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for Code to Figma Bridge — bridges Claude Code to Figma plugin via WebSocket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|
|
17
|
-
"prepublishOnly": "tsc",
|
|
17
|
+
"prepublishOnly": "tsc && chmod +x dist/index.js",
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"dev": "tsc && node dist/index.js"
|
|
20
20
|
},
|