This is page 1 of 8. Use http://codebase.md/1yhy/figma-context-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .lintstagedrc.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── Dockerfile
├── docs
│ ├── en
│ │ ├── absolute-to-relative-research.md
│ │ ├── architecture.md
│ │ ├── cache-architecture.md
│ │ ├── grid-layout-research.md
│ │ ├── icon-detection.md
│ │ ├── layout-detection-research.md
│ │ └── layout-detection.md
│ └── zh-CN
│ ├── absolute-to-relative-research.md
│ ├── architecture.md
│ ├── cache-architecture.md
│ ├── grid-layout-research.md
│ ├── icon-detection.md
│ ├── layout-detection-research.md
│ ├── layout-detection.md
│ └── TODO-feature-enhancements.md
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── scripts
│ ├── fetch-test-data.ts
│ └── optimize-figma-json.ts
├── smithery.yaml
├── src
│ ├── algorithms
│ │ ├── icon
│ │ │ ├── detector.ts
│ │ │ └── index.ts
│ │ └── layout
│ │ ├── detector.ts
│ │ ├── index.ts
│ │ ├── optimizer.ts
│ │ └── spatial.ts
│ ├── config.ts
│ ├── core
│ │ ├── effects.ts
│ │ ├── layout.ts
│ │ ├── parser.ts
│ │ └── style.ts
│ ├── index.ts
│ ├── prompts
│ │ ├── design-to-code.ts
│ │ └── index.ts
│ ├── resources
│ │ ├── figma-resources.ts
│ │ └── index.ts
│ ├── server.ts
│ ├── services
│ │ ├── cache
│ │ │ ├── cache-manager.ts
│ │ │ ├── disk-cache.ts
│ │ │ ├── index.ts
│ │ │ ├── lru-cache.ts
│ │ │ └── types.ts
│ │ ├── cache.ts
│ │ ├── figma.ts
│ │ └── simplify-node-response.ts
│ ├── types
│ │ ├── figma.ts
│ │ ├── index.ts
│ │ └── simplified.ts
│ └── utils
│ ├── color.ts
│ ├── css.ts
│ ├── file.ts
│ └── validation.ts
├── tests
│ ├── fixtures
│ │ ├── expected
│ │ │ ├── node-240-32163-optimized.json
│ │ │ ├── node-402-34955-optimized.json
│ │ │ └── real-node-data-optimized.json
│ │ └── figma-data
│ │ ├── node-240-32163.json
│ │ ├── node-402-34955.json
│ │ └── real-node-data.json
│ ├── integration
│ │ ├── __snapshots__
│ │ │ ├── layout-optimization.test.ts.snap
│ │ │ └── output-quality.test.ts.snap
│ │ ├── layout-optimization.test.ts
│ │ ├── output-quality.test.ts
│ │ └── parser.test.ts
│ ├── unit
│ │ ├── algorithms
│ │ │ ├── icon-optimization.test.ts
│ │ │ ├── icon.test.ts
│ │ │ └── layout.test.ts
│ │ ├── resources
│ │ │ └── figma-resources.test.ts
│ │ └── services
│ │ └── cache.test.ts
│ └── utils
│ ├── preview-generator.ts
│ ├── preview.ts
│ ├── run-simplification.ts
│ └── viewer.html
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
```
1 | v20
```
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
1 | {
2 | "semi": true,
3 | "trailingComma": "all",
4 | "singleQuote": false,
5 | "printWidth": 100,
6 | "tabWidth": 2,
7 | "useTabs": false
8 | }
9 |
```
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "src/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
3 | "tests/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
4 | "*.{json,md,yml,yaml}": ["prettier --write"]
5 | }
6 |
```
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
```
1 | # EditorConfig - https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_style = space
10 | indent_size = 2
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | [*.{json,yml,yaml}]
16 | indent_size = 2
17 |
18 | [Makefile]
19 | indent_style = tab
20 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules
3 | .pnpm-store
4 |
5 | # Build output
6 | dist
7 |
8 | # Environment variables
9 | .env
10 | .env.local
11 | .env.*.local
12 |
13 | # IDE
14 | .vscode/*
15 | !.vscode/extensions.json
16 | !.vscode/settings.json
17 | .idea
18 | *.suo
19 | *.ntvs*
20 | *.njsproj
21 | *.sln
22 | *.sw?
23 |
24 | # Logs
25 | logs
26 | *.log
27 | npm-debug.log*
28 | yarn-debug.log*
29 | yarn-error.log*
30 | pnpm-debug.log*
31 |
32 | # Testing
33 | coverage
34 | tests/utils/simplified-with-css.json
35 |
36 | # OS
37 | .DS_Store
38 | Thumbs.db
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | <h1 align="center">
2 | <br>
3 | <img src="https://upload.wikimedia.org/wikipedia/commons/3/33/Figma-logo.svg" alt="Figma MCP" width="80">
4 | <br>
5 | Figma Context MCP
6 | <br>
7 | </h1>
8 |
9 | <p align="center">
10 | <strong>MCP server for seamless Figma design integration with AI coding tools</strong>
11 | </p>
12 |
13 | <p align="center">
14 | <a href="https://smithery.ai/server/@1yhy/Figma-Context-MCP">
15 | <img src="https://smithery.ai/badge/@1yhy/Figma-Context-MCP" alt="Smithery Badge">
16 | </a>
17 | <a href="https://www.npmjs.com/package/@yhy2001/figma-mcp-server">
18 | <img src="https://img.shields.io/npm/v/@yhy2001/figma-mcp-server" alt="npm version">
19 | </a>
20 | <a href="https://github.com/1yhy/Figma-Context-MCP/blob/main/LICENSE">
21 | <img src="https://img.shields.io/github/license/1yhy/Figma-Context-MCP" alt="License">
22 | </a>
23 | <a href="https://github.com/1yhy/Figma-Context-MCP/stargazers">
24 | <img src="https://img.shields.io/github/stars/1yhy/Figma-Context-MCP" alt="Stars">
25 | </a>
26 | <img src="https://img.shields.io/badge/TypeScript-5.7-blue?logo=typescript" alt="TypeScript">
27 | <img src="https://img.shields.io/badge/MCP-1.24-green" alt="MCP SDK">
28 | </p>
29 |
30 | <p align="center">
31 | <a href="#features">Features</a> •
32 | <a href="#quick-start">Quick Start</a> •
33 | <a href="#mcp-capabilities">MCP Capabilities</a> •
34 | <a href="#architecture">Architecture</a> •
35 | <a href="#documentation">Documentation</a> •
36 | <a href="./README.zh-CN.md">中文文档</a>
37 | </p>
38 |
39 | ---
40 |
41 | ## What is This?
42 |
43 | Figma Context MCP is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that bridges Figma designs with AI coding assistants like [Cursor](https://cursor.sh/), [Windsurf](https://codeium.com/windsurf), and [Cline](https://cline.bot/).
44 |
45 | When AI tools can access Figma design data directly, they generate more accurate code on the first try—far better than using screenshots.
46 |
47 | > **Note**: This project is based on [Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP), with optimized data structures and intelligent layout detection algorithms.
48 |
49 | ## Features
50 |
51 | ### Core Capabilities
52 |
53 | | Capability | Description |
54 | | ------------------------------- | ------------------------------------------------------------------- |
55 | | **Smart Layout Detection** | Automatically infers Flexbox/Grid layouts from absolute positioning |
56 | | **Icon Merging** | Intelligently merges vector layers into single exportable icons |
57 | | **CSS Generation** | Converts Figma styles to clean, usable CSS |
58 | | **Image Export** | Downloads images and icons with proper naming |
59 | | **Multi-layer Caching** | L1 memory + L2 disk cache to reduce API calls |
60 | | **Design-to-Code Prompts** | Built-in professional prompt templates to guide AI code generation |
61 | | **Lightweight Resource Access** | Resources API provides low-token data access |
62 |
63 | ### Key Improvements
64 |
65 | | Feature | Before | After |
66 | | ---------------- | --------------- | ------------------------------- |
67 | | Icon exports | ~45 fragmented | 2 merged (96% reduction) |
68 | | Layout detection | Manual absolute | Auto Flexbox/Grid inference |
69 | | CSS output | Raw values | Optimized with defaults removed |
70 | | API calls | Every request | 24-hour smart caching |
71 |
72 | ## Quick Start
73 |
74 | ### Prerequisites
75 |
76 | - Node.js >= 18.0.0
77 | - A Figma account with API access
78 |
79 | ### Installation
80 |
81 | **Via Smithery (Recommended)**
82 |
83 | ```bash
84 | npx -y @smithery/cli install @1yhy/Figma-Context-MCP --client claude
85 | ```
86 |
87 | **Via npm**
88 |
89 | ```bash
90 | npm install -g @yhy2001/figma-mcp-server
91 | ```
92 |
93 | **From Source**
94 |
95 | ```bash
96 | git clone https://github.com/1yhy/Figma-Context-MCP.git
97 | cd Figma-Context-MCP
98 | pnpm install
99 | pnpm build
100 | ```
101 |
102 | ### Configuration
103 |
104 | #### 1. Get Figma API Token
105 |
106 | 1. Go to [Figma Account Settings](https://www.figma.com/settings)
107 | 2. Scroll to "Personal access tokens"
108 | 3. Click "Create new token"
109 | 4. Copy the token
110 |
111 | #### 2. Configure Your AI Tool
112 |
113 | <details>
114 | <summary><strong>Cursor / Windsurf / Cline</strong></summary>
115 |
116 | Add to your MCP configuration file:
117 |
118 | ```json
119 | {
120 | "mcpServers": {
121 | "Figma": {
122 | "command": "npx",
123 | "args": ["-y", "@yhy2001/figma-mcp-server", "--stdio"],
124 | "env": {
125 | "FIGMA_API_KEY": "your-figma-api-key"
126 | }
127 | }
128 | }
129 | }
130 | ```
131 |
132 | </details>
133 |
134 | <details>
135 | <summary><strong>HTTP/SSE Mode (Local Development)</strong></summary>
136 |
137 | ```bash
138 | # From source (development)
139 | cp .env.example .env # Add FIGMA_API_KEY to .env
140 | pnpm install && pnpm build
141 | pnpm start # Starts on port 3333
142 |
143 | # Or with environment variable
144 | FIGMA_API_KEY=<your-key> pnpm start
145 |
146 | # Or via global install
147 | figma-mcp --figma-api-key=<your-key> --port=3333
148 |
149 | # Connect via SSE
150 | # URL: http://localhost:3333/sse
151 | ```
152 |
153 | </details>
154 |
155 | ### Usage Example
156 |
157 | ```
158 | Please implement this Figma design: https://www.figma.com/design/abc123/MyDesign?node-id=1:234
159 |
160 | Use React and Tailwind CSS.
161 | ```
162 |
163 | ---
164 |
165 | ## MCP Capabilities
166 |
167 | This server provides full MCP capabilities support:
168 |
169 | ```
170 | ┌─────────────────────────────────────────────────────────────┐
171 | │ Figma MCP Server v1.1.0 │
172 | ├─────────────────────────────────────────────────────────────┤
173 | │ Tools (2) AI-invoked operations │
174 | │ ├── get_figma_data Fetch design data │
175 | │ └── download_figma_images Download image assets │
176 | ├─────────────────────────────────────────────────────────────┤
177 | │ Prompts (3) User-selected templates │
178 | │ ├── design_to_code Full design-to-code flow │
179 | │ ├── analyze_components Component structure │
180 | │ └── extract_styles Style token extraction │
181 | ├─────────────────────────────────────────────────────────────┤
182 | │ Resources (5) Lightweight data sources │
183 | │ ├── figma://help Usage guide │
184 | │ ├── figma://file/{key} File metadata (~200 tok) │
185 | │ ├── figma://file/{key}/styles Design tokens (~500 tok) │
186 | │ ├── figma://file/{key}/components Component list (~300 tok)│
187 | │ └── figma://file/{key}/assets Asset inventory (~400 tok) │
188 | └─────────────────────────────────────────────────────────────┘
189 | ```
190 |
191 | ### Tools
192 |
193 | | Tool | Description | Parameters |
194 | | ----------------------- | ---------------------------- | --------------------------------- |
195 | | `get_figma_data` | Fetch simplified design data | `fileKey`, `nodeId?`, `depth?` |
196 | | `download_figma_images` | Download images and icons | `fileKey`, `nodes[]`, `localPath` |
197 |
198 | ### Prompts
199 |
200 | Built-in professional prompt templates to help AI generate high-quality code:
201 |
202 | | Prompt | Description | Parameters |
203 | | -------------------- | ------------------------------------------- | ---------------------------------- |
204 | | `design_to_code` | Complete design-to-code workflow | `framework?`, `includeResponsive?` |
205 | | `analyze_components` | Analyze component structure and reusability | - |
206 | | `extract_styles` | Extract design tokens | - |
207 |
208 | **design_to_code workflow includes:**
209 |
210 | 1. **Project Analysis** - Read theme config, global styles, component library
211 | 2. **Structure Analysis** - Identify page patterns, component splitting strategy
212 | 3. **ASCII Layout Blueprint** - Generate layout diagram with component and asset annotations
213 | 4. **Asset Management** - Analyze, download, and organize images/icons
214 | 5. **Code Generation** - Generate code following project conventions
215 | 6. **Accessibility Optimization** - Semantic HTML, ARIA labels
216 | 7. **Responsive Adaptation** - Mobile layout adjustments
217 |
218 | ### Resources
219 |
220 | Lightweight data access to save tokens:
221 |
222 | ```bash
223 | # Get file metadata (~200 tokens)
224 | figma://file/abc123
225 |
226 | # Get design tokens (~500 tokens)
227 | figma://file/abc123/styles
228 |
229 | # Get component list (~300 tokens)
230 | figma://file/abc123/components
231 |
232 | # Get asset inventory (~400 tokens)
233 | figma://file/abc123/assets
234 | ```
235 |
236 | **Resources vs Tools comparison:**
237 |
238 | | Feature | Tools | Resources |
239 | | ---------- | ------------------ | --------------------- |
240 | | Controller | AI auto-invoked | User/client initiated |
241 | | Token cost | Higher (full data) | Lower (summaries) |
242 | | Use case | Execute actions | Browse and explore |
243 |
244 | ---
245 |
246 | ## Architecture
247 |
248 | ```
249 | ┌──────────────────────────────────────────────────────────────┐
250 | │ MCP Server │
251 | ├──────────────────────────────────────────────────────────────┤
252 | │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
253 | │ │ Tools │ │ Prompts │ │ Resources │ │
254 | │ │ (2 tools) │ │ (3 prompts) │ │ (5 resources) │ │
255 | │ └──────┬──────┘ └─────────────┘ └──────────┬──────────┘ │
256 | │ │ │ │
257 | │ └──────────────────┬───────────────────┘ │
258 | │ ▼ │
259 | │ ┌────────────────────────────────────────────────────────┐ │
260 | │ │ FigmaService │ │
261 | │ │ API Calls • Validation • Error Handling │ │
262 | │ └────────────────────────┬───────────────────────────────┘ │
263 | │ │ │
264 | │ ┌─────────────────┴─────────────────┐ │
265 | │ ▼ ▼ │
266 | │ ┌─────────────────┐ ┌─────────────────────┐ │
267 | │ │ CacheManager │ │ Parser + Algo │ │
268 | │ │ L1: LRU Memory │ │ • Layout Detection │ │
269 | │ │ L2: Disk Store │ │ • Icon Merging │ │
270 | │ └─────────────────┘ │ • CSS Generation │ │
271 | │ └─────────────────────┘ │
272 | └──────────────────────────────────────────────────────────────┘
273 | ```
274 |
275 | ### Cache System
276 |
277 | Two-layer cache architecture significantly reduces API calls:
278 |
279 | | Layer | Storage | Capacity | TTL | Purpose |
280 | | ----- | ---------- | --------------------- | -------- | -------------------- |
281 | | L1 | Memory LRU | 100 nodes / 50 images | 5-10 min | Hot data fast access |
282 | | L2 | Disk | 500MB | 24 hours | Persistent cache |
283 |
284 | ### Layout Detection Algorithm
285 |
286 | Automatically converts absolute positioning to semantic Flexbox/Grid layouts:
287 |
288 | ```
289 | Input (Figma absolute positioning):
290 | ┌─────────────────────────┐
291 | │ ■ (10,10) ■ (110,10) │
292 | │ ■ (10,60) ■ (110,60) │
293 | └─────────────────────────┘
294 |
295 | Output (Inferred Grid):
296 | display: grid
297 | grid-template-columns: 100px 100px
298 | grid-template-rows: 50px 50px
299 | gap: 10px
300 | ```
301 |
302 | ---
303 |
304 | ## Project Structure
305 |
306 | ```
307 | src/
308 | ├── algorithms/ # Smart algorithms
309 | │ ├── layout/ # Layout detection (Flex/Grid inference)
310 | │ └── icon/ # Icon merge detection
311 | ├── core/ # Core parsing
312 | │ ├── parser.ts # Figma data parser
313 | │ ├── style.ts # CSS style generation
314 | │ ├── layout.ts # Layout processing
315 | │ └── effects.ts # Effects handling
316 | ├── services/ # Service layer
317 | │ ├── figma.ts # Figma API client
318 | │ └── cache/ # Multi-layer cache system
319 | ├── prompts/ # MCP prompt templates
320 | ├── resources/ # MCP resource handlers
321 | ├── types/ # TypeScript type definitions
322 | ├── utils/ # Utility functions
323 | ├── server.ts # MCP server main entry
324 | └── index.ts # CLI entry
325 |
326 | tests/
327 | ├── fixtures/ # Test data
328 | │ ├── figma-data/ # Raw JSON from Figma API
329 | │ └── expected/ # Expected output snapshots
330 | ├── integration/ # Integration tests
331 | │ ├── layout-optimization.test.ts # Layout optimization tests
332 | │ ├── output-quality.test.ts # Output quality validation
333 | │ └── parser.test.ts # Parser tests
334 | └── unit/ # Unit tests
335 | ├── algorithms/ # Algorithm tests (layout, icon detection)
336 | ├── resources/ # Resource handler tests
337 | └── services/ # Service layer tests
338 |
339 | scripts/
340 | └── fetch-test-data.ts # Figma test data fetcher
341 | ```
342 |
343 | ---
344 |
345 | ## Documentation
346 |
347 | ### Core Algorithms
348 |
349 | | English | 中文 |
350 | | ----------------------------------------------------- | -------------------------------------------------- |
351 | | [Layout Detection](./docs/en/layout-detection.md) | [布局检测算法](./docs/zh-CN/layout-detection.md) |
352 | | [Icon Detection](./docs/en/icon-detection.md) | [图标检测算法](./docs/zh-CN/icon-detection.md) |
353 | | [Cache Architecture](./docs/en/cache-architecture.md) | [缓存架构设计](./docs/zh-CN/cache-architecture.md) |
354 |
355 | ### Research Documents
356 |
357 | | English | 中文 |
358 | | ------------------------------------------------------------------- | --------------------------------------------------------- |
359 | | [Grid Layout Research](./docs/en/grid-layout-research.md) | [Grid 布局研究](./docs/zh-CN/grid-layout-research.md) |
360 | | [Layout Detection Research](./docs/en/layout-detection-research.md) | [布局检测研究](./docs/zh-CN/layout-detection-research.md) |
361 |
362 | ### Architecture Documents
363 |
364 | | English | 中文 |
365 | | ----------------------------------------- | ---------------------------------------- |
366 | | [Architecture](./docs/en/architecture.md) | [系统架构](./docs/zh-CN/architecture.md) |
367 |
368 | ---
369 |
370 | ## Command Line Options
371 |
372 | | Option | Description | Default |
373 | | ----------------- | ------------------------- | -------- |
374 | | `--figma-api-key` | Figma API token | Required |
375 | | `--port` | Server port for HTTP mode | 3333 |
376 | | `--stdio` | Run in stdio mode | false |
377 | | `--help` | Show help | - |
378 |
379 | ---
380 |
381 | ## Contributing
382 |
383 | Contributions are welcome!
384 |
385 | ```bash
386 | # Setup
387 | git clone https://github.com/1yhy/Figma-Context-MCP.git
388 | cd Figma-Context-MCP
389 | pnpm install
390 |
391 | # Development
392 | pnpm dev # Watch mode
393 | pnpm test # Run tests (272 test cases)
394 | pnpm lint # Lint code
395 | pnpm build # Build
396 |
397 | # Debug
398 | pnpm inspect # MCP Inspector
399 |
400 | # Test with your own Figma data
401 | pnpm tsx scripts/fetch-test-data.ts <fileKey> <nodeId> <outputName>
402 |
403 | # Commit (uses conventional commits)
404 | git commit -m "feat: add new feature"
405 | ```
406 |
407 | ### Commit Types
408 |
409 | | Type | Description |
410 | | ---------- | ------------- |
411 | | `feat` | New feature |
412 | | `fix` | Bug fix |
413 | | `docs` | Documentation |
414 | | `style` | Code style |
415 | | `refactor` | Refactoring |
416 | | `test` | Tests |
417 | | `chore` | Maintenance |
418 |
419 | ### Release Process (Maintainers)
420 |
421 | ```bash
422 | # 1. Update version in package.json and CHANGELOG.md
423 |
424 | # 2. Commit version bump
425 | git add -A
426 | git commit -m "chore: bump version to x.x.x"
427 |
428 | # 3. Publish to npm (auto runs: type-check → lint → test → build)
429 | npm login --scope=@yhy2001 # if not logged in
430 | pnpm run pub:release
431 |
432 | # 4. Create git tag and push
433 | git tag vx.x.x
434 | git push origin main --tags
435 |
436 | # 5. Create GitHub Release (optional)
437 | # Go to https://github.com/1yhy/Figma-Context-MCP/releases/new
438 | ```
439 |
440 | ### Testing with Your Own Figma Data
441 |
442 | You can test the layout detection and optimization with your own Figma designs:
443 |
444 | #### 1. Configure Environment Variables
445 |
446 | ```bash
447 | # Copy the environment template
448 | cp .env.example .env
449 |
450 | # Edit .env file with your configuration
451 | FIGMA_API_KEY=your_figma_api_key_here
452 | TEST_FIGMA_FILE_KEY=your_file_key # Optional
453 | TEST_FIGMA_NODE_ID=your_node_id # Optional
454 | ```
455 |
456 | #### 2. Fetch Figma Node Data
457 |
458 | ```bash
459 | # Method 1: Using command line arguments (recommended)
460 | pnpm tsx scripts/fetch-test-data.ts <fileKey> <nodeId> <outputName>
461 |
462 | # Example: Fetch a specific node
463 | pnpm tsx scripts/fetch-test-data.ts UgtwrncR3GokKDIS7dpm4Z 402-34955 my-design
464 |
465 | # Method 2: Using environment variables
466 | TEST_FIGMA_FILE_KEY=xxx TEST_FIGMA_NODE_ID=123-456 pnpm tsx scripts/fetch-test-data.ts
467 | ```
468 |
469 | **Parameters:**
470 |
471 | | Parameter | Description | How to Get |
472 | | ------------ | --------------------- | ------------------------------------------------------------ |
473 | | `fileKey` | Figma file identifier | Part after `/design/` in URL, e.g., `UgtwrncR3GokKDIS7dpm4Z` |
474 | | `nodeId` | Node ID | `node-id=` parameter in URL, e.g., `402-34955` |
475 | | `outputName` | Output filename | Custom name, e.g., `my-design` |
476 |
477 | **Example URL Parsing:**
478 |
479 | ```
480 | https://www.figma.com/design/UgtwrncR3GokKDIS7dpm4Z/MyProject?node-id=402-34955
481 | ↑ fileKey ↑ nodeId
482 | ```
483 |
484 | #### 3. Run Tests to Validate Output
485 |
486 | ```bash
487 | # Run all tests
488 | pnpm test
489 |
490 | # Run only integration tests (validate layout optimization)
491 | pnpm test tests/integration/
492 |
493 | # View output JSON files
494 | ls tests/fixtures/figma-data/
495 | ```
496 |
497 | #### 4. Analyze Optimization Results
498 |
499 | Tests automatically validate:
500 |
501 | - **Data Compression** - Typically >50% compression
502 | - **Layout Detection** - Flex/Grid layout recognition rate
503 | - **CSS Properties** - Redundant property cleanup
504 | - **Output Quality** - Structural consistency checks
505 |
506 | If tests fail, the output may not meet expectations. Check error messages to adjust or report an issue.
507 |
508 | ---
509 |
510 | ## License
511 |
512 | [MIT](./LICENSE) © 1yhy
513 |
514 | ## Acknowledgments
515 |
516 | - [Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) - Original project
517 | - [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
518 | - [Best-README-Template](https://github.com/othneildrew/Best-README-Template) - README template reference
519 |
520 | ---
521 |
522 | <p align="center">
523 | Made with ❤️ for the AI coding community
524 | </p>
525 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Contributing to Figma Context MCP
2 |
3 | Thank you for your interest in contributing to Figma Context MCP! This document provides guidelines and instructions for contributing.
4 |
5 | ## Table of Contents
6 |
7 | - [Code of Conduct](#code-of-conduct)
8 | - [Getting Started](#getting-started)
9 | - [Development Setup](#development-setup)
10 | - [Making Changes](#making-changes)
11 | - [Commit Guidelines](#commit-guidelines)
12 | - [Pull Request Process](#pull-request-process)
13 | - [Code Style](#code-style)
14 |
15 | ## Code of Conduct
16 |
17 | Please be respectful and constructive in all interactions. We welcome contributors from all backgrounds and experience levels.
18 |
19 | ## Getting Started
20 |
21 | 1. Fork the repository
22 | 2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/Figma-Context-MCP.git`
23 | 3. Add upstream remote: `git remote add upstream https://github.com/1yhy/Figma-Context-MCP.git`
24 |
25 | ## Development Setup
26 |
27 | ### Prerequisites
28 |
29 | - Node.js >= 18.0.0
30 | - pnpm (recommended) or npm
31 |
32 | ### Installation
33 |
34 | ```bash
35 | # Install dependencies
36 | pnpm install
37 |
38 | # Build the project
39 | pnpm build
40 |
41 | # Run in development mode (with watch)
42 | pnpm dev
43 | ```
44 |
45 | ### Running Tests
46 |
47 | ```bash
48 | # Run all tests
49 | pnpm test
50 |
51 | # Run specific test suites
52 | pnpm test:layout # Layout detection tests
53 | pnpm test:icon # Icon detection tests
54 | pnpm test:all # All tests including final output
55 | ```
56 |
57 | ### Code Quality
58 |
59 | ```bash
60 | # Type checking
61 | pnpm type-check
62 |
63 | # Linting
64 | pnpm lint
65 | pnpm lint:fix
66 |
67 | # Formatting
68 | pnpm format
69 | ```
70 |
71 | ## Making Changes
72 |
73 | 1. Create a new branch from `main`:
74 |
75 | ```bash
76 | git checkout -b feat/your-feature-name
77 | # or
78 | git checkout -b fix/your-bug-fix
79 | ```
80 |
81 | 2. Make your changes following the code style guidelines
82 |
83 | 3. Test your changes locally
84 |
85 | 4. Commit your changes following the commit guidelines
86 |
87 | ## Commit Guidelines
88 |
89 | We use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages.
90 |
91 | ### Format
92 |
93 | ```
94 | <type>(<scope>): <description>
95 |
96 | [optional body]
97 |
98 | [optional footer]
99 | ```
100 |
101 | ### Types
102 |
103 | | Type | Description |
104 | | ---------- | -------------------------------------------------- |
105 | | `feat` | New feature |
106 | | `fix` | Bug fix |
107 | | `docs` | Documentation only |
108 | | `style` | Code style (formatting, missing semi-colons, etc.) |
109 | | `refactor` | Code refactoring (no functional changes) |
110 | | `perf` | Performance improvement |
111 | | `test` | Adding or updating tests |
112 | | `chore` | Maintenance tasks |
113 |
114 | ### Examples
115 |
116 | ```bash
117 | feat(layout): add support for grid layout detection
118 | fix(parser): handle empty node arrays correctly
119 | docs: update installation instructions
120 | refactor(core): simplify node processing logic
121 | ```
122 |
123 | ## Pull Request Process
124 |
125 | 1. Update documentation if needed
126 | 2. Ensure all tests pass: `pnpm test`
127 | 3. Ensure code quality checks pass: `pnpm lint && pnpm type-check`
128 | 4. Fill out the pull request template completely
129 | 5. Request review from maintainers
130 |
131 | ### PR Title
132 |
133 | Use the same format as commit messages:
134 |
135 | ```
136 | feat(layout): add support for grid layout detection
137 | ```
138 |
139 | ## Code Style
140 |
141 | ### TypeScript
142 |
143 | - Use TypeScript strict mode
144 | - Prefer `interface` over `type` for object shapes
145 | - Use explicit return types for public functions
146 | - Avoid `any` - use `unknown` if type is truly unknown
147 |
148 | ### Formatting
149 |
150 | - We use Prettier for code formatting
151 | - Run `pnpm format` before committing
152 | - EditorConfig is provided for consistent editor settings
153 |
154 | ### File Organization
155 |
156 | ```
157 | src/
158 | ├── algorithms/ # Detection algorithms (layout, icon)
159 | ├── core/ # Core parsing logic
160 | ├── services/ # External services (Figma API, cache)
161 | ├── types/ # TypeScript type definitions
162 | └── utils/ # Utility functions
163 | ```
164 |
165 | ### Naming Conventions
166 |
167 | | Type | Convention | Example |
168 | | ---------- | ------------------------------------- | -------------------- |
169 | | Files | kebab-case | `layout-detector.ts` |
170 | | Classes | PascalCase | `LayoutOptimizer` |
171 | | Functions | camelCase | `detectLayout()` |
172 | | Constants | UPPER_SNAKE_CASE | `MAX_ICON_SIZE` |
173 | | Interfaces | PascalCase with `I` prefix (optional) | `SimplifiedNode` |
174 |
175 | ## Questions?
176 |
177 | If you have questions, feel free to:
178 |
179 | - Open an issue with the "question" label
180 | - Check existing issues and discussions
181 |
182 | Thank you for contributing!
183 |
```
--------------------------------------------------------------------------------
/src/prompts/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export {
2 | DESIGN_TO_CODE_PROMPT,
3 | COMPONENT_ANALYSIS_PROMPT,
4 | STYLE_EXTRACTION_PROMPT,
5 | } from "./design-to-code.js";
6 |
```
--------------------------------------------------------------------------------
/src/services/cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Cache Service
3 | *
4 | * Re-exports from the cache module for convenience.
5 | *
6 | * @module services/cache
7 | */
8 |
9 | export {
10 | CacheManager,
11 | cacheManager,
12 | type CacheConfig,
13 | type CacheStatistics,
14 | } from "./cache/index.js";
15 |
```
--------------------------------------------------------------------------------
/src/resources/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export {
2 | getFileMetadata,
3 | getStyleTokens,
4 | getComponentList,
5 | getAssetList,
6 | createFileMetadataTemplate,
7 | createStylesTemplate,
8 | createComponentsTemplate,
9 | createAssetsTemplate,
10 | FIGMA_MCP_HELP,
11 | type FileMetadata,
12 | type StyleTokens,
13 | type ComponentSummary,
14 | } from "./figma-resources.js";
15 |
```
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineConfig } from "tsup";
2 |
3 | const isDev = process.env.npm_lifecycle_event === "dev";
4 |
5 | export default defineConfig({
6 | clean: true,
7 | entry: ["src/index.ts"],
8 | format: ["esm"],
9 | minify: !isDev,
10 | target: "esnext",
11 | outDir: "dist",
12 | outExtension: ({ format }) => ({
13 | js: ".js",
14 | }),
15 | onSuccess: isDev ? "node dist/index.js" : undefined,
16 | });
17 |
```
--------------------------------------------------------------------------------
/src/algorithms/icon/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Icon Detection Algorithm Module
3 | *
4 | * Exports the icon detection algorithm for identifying and merging
5 | * fragmented icon layers in Figma designs.
6 | *
7 | * @module algorithms/icon
8 | */
9 |
10 | export {
11 | detectIcon,
12 | processNodeTree,
13 | collectExportableIcons,
14 | analyzeNodeTree,
15 | DEFAULT_CONFIG,
16 | type FigmaNode,
17 | type IconDetectionResult,
18 | type DetectionConfig,
19 | } from "./detector.js";
20 |
```
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineConfig } from "vitest/config";
2 | import { resolve } from "path";
3 |
4 | export default defineConfig({
5 | test: {
6 | globals: true,
7 | environment: "node",
8 | include: ["tests/**/*.test.ts"],
9 | coverage: {
10 | provider: "v8",
11 | reporter: ["text", "json", "html"],
12 | include: ["src/**/*.ts"],
13 | exclude: ["src/index.ts", "src/types/**"],
14 | },
15 | testTimeout: 10000,
16 | },
17 | resolve: {
18 | alias: {
19 | "~": resolve(__dirname, "./src"),
20 | },
21 | },
22 | });
23 |
```
--------------------------------------------------------------------------------
/src/services/simplify-node-response.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Simplified Node Response Service
3 | *
4 | * This module re-exports the core parser functionality for backward compatibility.
5 | * The actual parsing logic has been moved to ~/core/parser.ts.
6 | *
7 | * @module services/simplify-node-response
8 | */
9 |
10 | // Re-export parser function
11 | export { parseFigmaResponse } from "~/core/parser.js";
12 |
13 | // Re-export types for backward compatibility
14 | export type {
15 | CSSStyle,
16 | TextStyle,
17 | SimplifiedDesign,
18 | SimplifiedNode,
19 | SimplifiedFill,
20 | ExportInfo,
21 | ImageResource,
22 | FigmaNodeType,
23 | } from "~/types/index.js";
24 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | FROM node:lts-alpine
3 |
4 | # Install pnpm globally
5 | RUN npm install -g pnpm
6 |
7 | # Set working directory
8 | WORKDIR /app
9 |
10 | # Copy package files and install dependencies (cache layer)
11 | COPY package.json pnpm-lock.yaml ./
12 | RUN pnpm install
13 |
14 | # Copy all source files
15 | COPY . .
16 |
17 | # Build the project
18 | RUN pnpm run build
19 |
20 | # Install this package globally so that the 'figma-mcp' command is available
21 | RUN npm install -g .
22 |
23 | # Expose the port (default 3333)
24 | EXPOSE 3333
25 |
26 | # Default command to run the MCP server
27 | CMD [ "figma-mcp", "--stdio" ]
28 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "~/*": ["./src/*"]
6 | },
7 |
8 | "target": "ES2020",
9 | "lib": ["ES2021", "DOM"],
10 | "module": "NodeNext",
11 | "moduleResolution": "NodeNext",
12 | "resolveJsonModule": true,
13 | "allowJs": true,
14 | "checkJs": true,
15 |
16 | /* EMIT RULES */
17 | "outDir": "./dist",
18 | "declaration": true,
19 | "declarationMap": true,
20 | "sourceMap": true,
21 | "removeComments": true,
22 |
23 | "strict": true,
24 | "esModuleInterop": true,
25 | "skipLibCheck": true,
26 | "forceConsistentCasingInFileNames": true
27 | },
28 | "include": ["src/**/*", "tests/**/*"]
29 | }
30 |
```
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Figma Context MCP - Type Definitions
3 | *
4 | * Central export point for all type definitions.
5 | * Types are organized into separate modules by domain.
6 | *
7 | * @module types
8 | */
9 |
10 | // Figma API types
11 | export type {
12 | FigmaNodeType,
13 | ImageResource,
14 | ExportFormat,
15 | ExportInfo,
16 | FigmaError,
17 | RateLimitInfo,
18 | FetchImageParams,
19 | FetchImageFillParams,
20 | } from "./figma.js";
21 |
22 | // Simplified output types
23 | export type {
24 | CSSHexColor,
25 | CSSRGBAColor,
26 | CSSStyle,
27 | TextStyle,
28 | SimplifiedFill,
29 | SimplifiedNode,
30 | SimplifiedDesign,
31 | IconDetectionResult,
32 | IconDetectionConfig,
33 | LayoutInfo,
34 | } from "./simplified.js";
35 |
```
--------------------------------------------------------------------------------
/src/services/cache/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Cache Module
3 | *
4 | * Multi-layer caching system for Figma data.
5 | *
6 | * @module services/cache
7 | */
8 |
9 | // Types
10 | export type {
11 | CacheConfig,
12 | MemoryCacheConfig,
13 | DiskCacheConfig,
14 | CacheEntryMeta,
15 | NodeCacheEntry,
16 | ImageCacheEntry,
17 | MemoryCacheStats,
18 | DiskCacheStats,
19 | CacheStatistics,
20 | } from "./types.js";
21 |
22 | export { DEFAULT_MEMORY_CONFIG, DEFAULT_DISK_CONFIG, DEFAULT_CACHE_CONFIG } from "./types.js";
23 |
24 | // LRU Cache
25 | export { LRUCache, NodeLRUCache, type LRUCacheConfig, type CacheStats } from "./lru-cache.js";
26 |
27 | // Disk Cache
28 | export { DiskCache } from "./disk-cache.js";
29 |
30 | // Cache Manager
31 | export { CacheManager, cacheManager } from "./cache-manager.js";
32 |
```
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
```javascript
1 | export default {
2 | extends: ["@commitlint/config-conventional"],
3 | rules: {
4 | "type-enum": [
5 | 2,
6 | "always",
7 | [
8 | "feat", // New feature
9 | "fix", // Bug fix
10 | "docs", // Documentation
11 | "style", // Code style (formatting, etc.)
12 | "refactor", // Code refactoring
13 | "perf", // Performance improvement
14 | "test", // Tests
15 | "build", // Build system
16 | "ci", // CI configuration
17 | "chore", // Maintenance
18 | "revert", // Revert commit
19 | ],
20 | ],
21 | "subject-case": [0], // Disable case checking for Chinese commits
22 | "header-max-length": [2, "always", 100],
23 | },
24 | };
25 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | type: object
8 | required:
9 | - figmaApiKey
10 | properties:
11 | figmaApiKey:
12 | type: string
13 | description: Your Figma API access token
14 | port:
15 | type: number
16 | default: 3333
17 | description: Port for the server to run on (default 3333)
18 | commandFunction:
19 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
20 | |-
21 | (config) => ({
22 | command: 'figma-mcp',
23 | args: [`--figma-api-key=${config.figmaApiKey}`, '--stdio', `--port=${config.port}`],
24 | env: {}
25 | })
26 | exampleConfig:
27 | figmaApiKey: dummy-figma-api-key
28 | port: 3333
29 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 | import { FigmaMcpServer } from "./server.js";
5 | import { getServerConfig } from "./config.js";
6 | import { resolve } from "path";
7 | import { config } from "dotenv";
8 |
9 | // Load .env from the current working directory
10 | config({ path: resolve(process.cwd(), ".env") });
11 |
12 | export async function startServer(): Promise<void> {
13 | // Check if we're running in stdio mode (e.g., via CLI)
14 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio");
15 |
16 | const config = getServerConfig(isStdioMode);
17 |
18 | const server = new FigmaMcpServer(config.figmaApiKey);
19 |
20 | if (isStdioMode) {
21 | const transport = new StdioServerTransport();
22 | await server.connect(transport);
23 | } else {
24 | console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`);
25 | await server.startHttpServer(config.port);
26 | }
27 | }
28 |
29 | startServer().catch((error) => {
30 | console.error("Failed to start server:", error);
31 | process.exit(1);
32 | });
33 |
```
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
```markdown
1 | ## Description
2 |
3 | <!-- Describe your changes in detail -->
4 |
5 | ## Related Issue
6 |
7 | <!-- Link to the issue this PR addresses (if applicable) -->
8 | <!-- Fixes #123 -->
9 |
10 | ## Type of Change
11 |
12 | <!-- Mark the relevant option with an "x" -->
13 |
14 | - [ ] Bug fix (non-breaking change that fixes an issue)
15 | - [ ] New feature (non-breaking change that adds functionality)
16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
17 | - [ ] Documentation update
18 | - [ ] Refactoring (no functional changes)
19 |
20 | ## Checklist
21 |
22 | - [ ] My code follows the project's style guidelines
23 | - [ ] I have performed a self-review of my code
24 | - [ ] I have added/updated comments for hard-to-understand areas
25 | - [ ] I have updated the documentation (if applicable)
26 | - [ ] My changes generate no new warnings
27 | - [ ] I have run `pnpm lint` and `pnpm build` successfully
28 | - [ ] I have tested my changes locally
29 |
30 | ## Screenshots (if applicable)
31 |
32 | <!-- Add screenshots to help explain your changes -->
33 |
34 | ## Additional Notes
35 |
36 | <!-- Any additional information that reviewers should know -->
37 |
```
--------------------------------------------------------------------------------
/src/core/style.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { type Node as FigmaDocumentNode } from "@figma/rest-api-spec";
2 | import type { SimplifiedFill } from "~/types/index.js";
3 | import { generateCSSShorthand } from "~/utils/css.js";
4 | import { isVisible } from "~/utils/validation.js";
5 | import { parsePaint } from "~/utils/color.js";
6 | import { hasValue, isStrokeWeights } from "~/utils/validation.js";
7 | export type SimplifiedStroke = {
8 | colors: SimplifiedFill[];
9 | strokeWeight?: string;
10 | strokeDashes?: number[];
11 | strokeWeights?: string;
12 | };
13 | export function buildSimplifiedStrokes(n: FigmaDocumentNode): SimplifiedStroke {
14 | const strokes: SimplifiedStroke = { colors: [] };
15 | if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
16 | strokes.colors = n.strokes.filter(isVisible).map(parsePaint);
17 | }
18 |
19 | if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
20 | strokes.strokeWeight = `${n.strokeWeight}px`;
21 | }
22 |
23 | if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
24 | strokes.strokeDashes = n.strokeDashes;
25 | }
26 |
27 | if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
28 | strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
29 | }
30 |
31 | return strokes;
32 | }
33 |
```
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
1 | import eslint from "@eslint/js";
2 | import tseslint from "typescript-eslint";
3 | import prettierConfig from "eslint-config-prettier";
4 |
5 | export default tseslint.config(
6 | eslint.configs.recommended,
7 | ...tseslint.configs.recommended,
8 | prettierConfig,
9 | {
10 | ignores: ["dist/**", "node_modules/**", "*.config.js"],
11 | },
12 | {
13 | languageOptions: {
14 | ecmaVersion: 2022,
15 | sourceType: "module",
16 | parserOptions: {
17 | project: "./tsconfig.json",
18 | },
19 | },
20 | rules: {
21 | // TypeScript specific
22 | "@typescript-eslint/explicit-function-return-type": "off",
23 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
24 | "@typescript-eslint/no-explicit-any": "warn",
25 | "@typescript-eslint/no-non-null-assertion": "warn",
26 | "@typescript-eslint/consistent-type-imports": [
27 | "error",
28 | { prefer: "type-imports", fixStyle: "inline-type-imports" },
29 | ],
30 |
31 | // General
32 | "no-console": ["warn", { allow: ["warn", "error"] }],
33 | "prefer-const": "error",
34 | "no-var": "error",
35 | eqeqeq: ["error", "always"],
36 | },
37 | },
38 | // Test files - allow console.log and non-null assertions
39 | {
40 | files: ["tests/**/*.ts"],
41 | rules: {
42 | "no-console": "off",
43 | "@typescript-eslint/no-non-null-assertion": "off",
44 | },
45 | },
46 | );
47 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Feature Request
2 | description: Suggest a new feature or improvement
3 | labels: ["enhancement"]
4 |
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for suggesting a feature! Please fill out the form below.
10 |
11 | - type: textarea
12 | id: problem
13 | attributes:
14 | label: Problem Statement
15 | description: Is your feature request related to a problem? Please describe.
16 | placeholder: "I'm always frustrated when..."
17 | validations:
18 | required: true
19 |
20 | - type: textarea
21 | id: solution
22 | attributes:
23 | label: Proposed Solution
24 | description: Describe the solution you'd like to see.
25 | placeholder: "I would like to be able to..."
26 | validations:
27 | required: true
28 |
29 | - type: textarea
30 | id: alternatives
31 | attributes:
32 | label: Alternatives Considered
33 | description: Describe any alternative solutions or features you've considered.
34 | placeholder: "I've also thought about..."
35 |
36 | - type: dropdown
37 | id: priority
38 | attributes:
39 | label: Priority
40 | description: How important is this feature to you?
41 | options:
42 | - Nice to have
43 | - Important
44 | - Critical
45 | validations:
46 | required: true
47 |
48 | - type: checkboxes
49 | id: contribution
50 | attributes:
51 | label: Contribution
52 | description: Are you willing to contribute to this feature?
53 | options:
54 | - label: I'm willing to submit a PR for this feature
55 |
```
--------------------------------------------------------------------------------
/src/algorithms/layout/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Layout Detection Algorithm Module
3 | *
4 | * Exports spatial analysis utilities for detecting layout patterns
5 | * in Figma designs, including row/column grouping and containment analysis.
6 | *
7 | * @module algorithms/layout
8 | */
9 |
10 | // Spatial analysis utilities
11 | export {
12 | RectUtils,
13 | SpatialProjectionAnalyzer,
14 | NodeRelationship,
15 | type Rect,
16 | type ProjectionLine,
17 | } from "./spatial.js";
18 |
19 | // Layout optimizer
20 | export { LayoutOptimizer } from "./optimizer.js";
21 |
22 | // Layout detection algorithm
23 | export {
24 | // Types
25 | type BoundingBox,
26 | type ElementRect,
27 | type LayoutGroup,
28 | type LayoutAnalysisResult,
29 | type LayoutNode,
30 | type GridAnalysisResult,
31 | type OverlapType,
32 | type OverlapDetectionResult,
33 | // Bounding box utilities
34 | extractBoundingBox,
35 | toElementRect,
36 | calculateBounds,
37 | // Overlap detection
38 | isOverlappingY,
39 | isOverlappingX,
40 | isFullyOverlapping,
41 | calculateIoU,
42 | classifyOverlap,
43 | detectOverlappingElements,
44 | // Background element detection
45 | detectBackgroundElement,
46 | type BackgroundDetectionResult,
47 | // Grouping
48 | groupIntoRows,
49 | groupIntoColumns,
50 | findOverlappingElements,
51 | // Gap analysis
52 | calculateGaps,
53 | analyzeGaps,
54 | roundToCommonValue,
55 | // Alignment
56 | areValuesAligned,
57 | analyzeAlignment,
58 | toJustifyContent,
59 | toAlignItems,
60 | // Layout detection
61 | detectLayoutDirection,
62 | analyzeLayout,
63 | buildLayoutTree,
64 | generateLayoutReport,
65 | // Grid detection
66 | clusterValues,
67 | detectGridLayout,
68 | // Homogeneity analysis
69 | analyzeHomogeneity,
70 | filterHomogeneousForGrid,
71 | type HomogeneityResult,
72 | } from "./detector.js";
73 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 | branches: [main, master]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [18.x, 20.x, 22.x]
16 |
17 | steps:
18 | - name: Checkout repository
19 | uses: actions/checkout@v4
20 |
21 | - name: Install pnpm
22 | uses: pnpm/action-setup@v4
23 | with:
24 | version: 9
25 |
26 | - name: Setup Node.js ${{ matrix.node-version }}
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: ${{ matrix.node-version }}
30 | cache: "pnpm"
31 |
32 | - name: Install dependencies
33 | run: pnpm install --frozen-lockfile
34 |
35 | - name: Type check
36 | run: pnpm type-check
37 |
38 | - name: Lint
39 | run: pnpm lint
40 |
41 | - name: Build
42 | run: pnpm build
43 |
44 | release:
45 | needs: build
46 | runs-on: ubuntu-latest
47 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
48 |
49 | steps:
50 | - name: Checkout repository
51 | uses: actions/checkout@v4
52 |
53 | - name: Install pnpm
54 | uses: pnpm/action-setup@v4
55 | with:
56 | version: 9
57 |
58 | - name: Setup Node.js
59 | uses: actions/setup-node@v4
60 | with:
61 | node-version: 20.x
62 | cache: "pnpm"
63 | registry-url: "https://registry.npmjs.org"
64 |
65 | - name: Install dependencies
66 | run: pnpm install --frozen-lockfile
67 |
68 | - name: Build
69 | run: pnpm build
70 |
71 | # Uncomment to enable auto-publish on main branch
72 | # - name: Publish to npm
73 | # run: npm publish --access public
74 | # env:
75 | # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
76 |
```
--------------------------------------------------------------------------------
/tests/utils/preview.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Generate simplified output and open viewer for comparison
3 | *
4 | * Usage: pnpm preview
5 | */
6 |
7 | import * as fs from "fs";
8 | import * as path from "path";
9 | import { fileURLToPath } from "url";
10 | import { exec } from "child_process";
11 | import { parseFigmaResponse } from "../../src/services/simplify-node-response.js";
12 |
13 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
14 | const fixturesDir = path.join(__dirname, "../fixtures");
15 |
16 | const originalPath = path.join(fixturesDir, "figma-data/real-node-data.json");
17 | const outputPath = path.join(__dirname, "simplified-with-css.json");
18 | const viewerPath = path.join(__dirname, "viewer.html");
19 |
20 | async function main() {
21 | console.log("📖 Reading original Figma data...");
22 | const originalData = JSON.parse(fs.readFileSync(originalPath, "utf-8"));
23 |
24 | console.log("⚙️ Running simplification...");
25 | const simplified = parseFigmaResponse(originalData);
26 |
27 | console.log("💾 Saving output...");
28 | fs.writeFileSync(outputPath, JSON.stringify(simplified, null, 2));
29 |
30 | const originalSize = fs.statSync(originalPath).size;
31 | const simplifiedSize = fs.statSync(outputPath).size;
32 | const compressionRate = ((1 - simplifiedSize / originalSize) * 100).toFixed(1);
33 |
34 | console.log("");
35 | console.log("📊 Results:");
36 | console.log(` Original: ${(originalSize / 1024).toFixed(1)} KB`);
37 | console.log(` Simplified: ${(simplifiedSize / 1024).toFixed(1)} KB`);
38 | console.log(` Compression: ${compressionRate}%`);
39 | console.log("");
40 |
41 | // Open viewer in browser
42 | console.log("🌐 Opening viewer...");
43 | const openCommand =
44 | process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
45 |
46 | exec(`${openCommand} "${viewerPath}"`, (error) => {
47 | if (error) {
48 | console.log(` Viewer path: file://${viewerPath}`);
49 | console.log(" (Please open manually in browser)");
50 | } else {
51 | console.log(" Viewer opened in browser");
52 | }
53 | });
54 | }
55 |
56 | main().catch(console.error);
57 |
```
--------------------------------------------------------------------------------
/src/utils/file.ts:
--------------------------------------------------------------------------------
```typescript
1 | // ==================== Name Processing ====================
2 |
3 | /**
4 | * Clean illegal characters from name (for file names)
5 | *
6 | * @param name Original name
7 | * @param separator Separator, defaults to '_'
8 | * @returns Sanitized name
9 | */
10 | export function sanitizeName(name: string, separator: string = "_"): string {
11 | return name
12 | .replace(/[/\\?%*:|"<>]/g, separator) // Replace illegal filesystem characters
13 | .replace(/\s+/g, separator) // Replace whitespace characters
14 | .replace(new RegExp(`${separator}+`, "g"), separator) // Merge consecutive separators
15 | .toLowerCase();
16 | }
17 |
18 | /**
19 | * Clean name for ID generation (only keep alphanumeric and hyphens)
20 | *
21 | * @param name Original name
22 | * @returns Sanitized name
23 | */
24 | export function sanitizeNameForId(name: string): string {
25 | return name
26 | .replace(/\s+/g, "-")
27 | .replace(/[^a-zA-Z0-9-]/g, "")
28 | .toLowerCase();
29 | }
30 |
31 | /**
32 | * Generate file name based on node name
33 | */
34 | export function generateFileName(name: string, format: string): string {
35 | const sanitizedName = sanitizeName(name, "_");
36 | const lowerFormat = format.toLowerCase();
37 |
38 | // If the name already includes the extension, keep the original name
39 | if (sanitizedName.includes(`.${lowerFormat}`)) {
40 | return sanitizedName;
41 | }
42 |
43 | return `${sanitizedName}.${lowerFormat}`;
44 | }
45 |
46 | // ==================== Export Format Detection ====================
47 |
48 | /**
49 | * Node interface for format detection
50 | */
51 | export interface FormatDetectionNode {
52 | type?: string;
53 | exportSettings?: { format?: string[] };
54 | cssStyles?: { backgroundImage?: string };
55 | exportInfo?: { format?: string };
56 | children?: FormatDetectionNode[];
57 | }
58 |
59 | /**
60 | * Choose appropriate export format based on node characteristics
61 | *
62 | * @param node Node object
63 | * @param isSVGNode SVG detection function
64 | * @returns Recommended export format
65 | */
66 | export function suggestExportFormat(
67 | node: FormatDetectionNode,
68 | isSVGNode: (node: FormatDetectionNode) => boolean,
69 | ): "PNG" | "JPG" | "SVG" {
70 | if (isSVGNode(node)) {
71 | return "SVG";
72 | }
73 | return "PNG";
74 | }
75 |
```
--------------------------------------------------------------------------------
/src/types/figma.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Figma API Type Definitions
3 | *
4 | * Types related to Figma API interactions, including node types,
5 | * export formats, and API response structures.
6 | *
7 | * @module types/figma
8 | */
9 |
10 | // ==================== Node Types ====================
11 |
12 | /** Figma node types */
13 | export type FigmaNodeType =
14 | | "DOCUMENT"
15 | | "CANVAS"
16 | | "FRAME"
17 | | "GROUP"
18 | | "TEXT"
19 | | "VECTOR"
20 | | "RECTANGLE"
21 | | "ELLIPSE"
22 | | "LINE"
23 | | "POLYGON"
24 | | "STAR"
25 | | "BOOLEAN_OPERATION"
26 | | "REGULAR_POLYGON"
27 | | "INSTANCE"
28 | | "COMPONENT"
29 | | string;
30 |
31 | /**
32 | * Image resource reference
33 | */
34 | export interface ImageResource {
35 | /** Image reference ID for downloading */
36 | imageRef: string;
37 | }
38 |
39 | // ==================== Export Types ====================
40 |
41 | /**
42 | * Export format options
43 | */
44 | export type ExportFormat = "PNG" | "JPG" | "SVG";
45 |
46 | /**
47 | * Export information for image nodes
48 | */
49 | export interface ExportInfo {
50 | /** Export type (single image or image group) */
51 | type: "IMAGE" | "IMAGE_GROUP";
52 | /** Recommended export format */
53 | format: ExportFormat;
54 | /** Node ID for API calls (optional, defaults to node.id) */
55 | nodeId?: string;
56 | /** Suggested file name */
57 | fileName?: string;
58 | }
59 |
60 | // ==================== API Types ====================
61 |
62 | /**
63 | * Figma API error
64 | */
65 | export interface FigmaError {
66 | status: number;
67 | err: string;
68 | rateLimitInfo?: RateLimitInfo;
69 | }
70 |
71 | /**
72 | * Rate limit information from Figma API
73 | */
74 | export interface RateLimitInfo {
75 | /** Remaining requests */
76 | remaining: number | null;
77 | /** Reset time in seconds */
78 | resetAfter: number | null;
79 | /** Retry wait time in seconds */
80 | retryAfter: number | null;
81 | }
82 |
83 | /**
84 | * Image download parameters
85 | */
86 | export interface FetchImageParams {
87 | /** Figma node ID */
88 | nodeId: string;
89 | /** Local file name */
90 | fileName: string;
91 | /** File format */
92 | fileType: "png" | "svg";
93 | }
94 |
95 | /**
96 | * Image fill download parameters
97 | */
98 | export interface FetchImageFillParams {
99 | /** Node ID */
100 | nodeId: string;
101 | /** Local file name */
102 | fileName: string;
103 | /** Image reference ID */
104 | imageRef: string;
105 | }
106 |
```
--------------------------------------------------------------------------------
/src/core/effects.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | type DropShadowEffect,
3 | type InnerShadowEffect,
4 | type BlurEffect,
5 | type Node as FigmaDocumentNode,
6 | } from "@figma/rest-api-spec";
7 | import { formatRGBAColor } from "~/utils/color.js";
8 | import { hasValue } from "~/utils/validation.js";
9 |
10 | export type SimplifiedEffects = {
11 | boxShadow?: string;
12 | filter?: string;
13 | backdropFilter?: string;
14 | };
15 |
16 | export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects {
17 | if (!hasValue("effects", n)) return {};
18 | const effects = n.effects.filter((e) => e.visible);
19 |
20 | // Handle drop and inner shadows (both go into CSS box-shadow)
21 | const dropShadows = effects
22 | .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW")
23 | .map(simplifyDropShadow);
24 |
25 | const innerShadows = effects
26 | .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW")
27 | .map(simplifyInnerShadow);
28 |
29 | const boxShadow = [...dropShadows, ...innerShadows].join(", ");
30 |
31 | // Handle blur effects - separate by CSS property
32 | // Layer blurs use the CSS 'filter' property
33 | const filterBlurValues = effects
34 | .filter((e): e is BlurEffect => e.type === "LAYER_BLUR")
35 | .map(simplifyBlur)
36 | .join(" ");
37 |
38 | // Background blurs use the CSS 'backdrop-filter' property
39 | const backdropFilterValues = effects
40 | .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR")
41 | .map(simplifyBlur)
42 | .join(" ");
43 |
44 | const result: SimplifiedEffects = {};
45 | if (boxShadow) result.boxShadow = boxShadow;
46 | if (filterBlurValues) result.filter = filterBlurValues;
47 | if (backdropFilterValues) result.backdropFilter = backdropFilterValues;
48 |
49 | return result;
50 | }
51 |
52 | function simplifyDropShadow(effect: DropShadowEffect) {
53 | return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
54 | }
55 |
56 | function simplifyInnerShadow(effect: InnerShadowEffect) {
57 | return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
58 | }
59 |
60 | function simplifyBlur(effect: BlurEffect) {
61 | return `blur(${effect.radius}px)`;
62 | }
63 |
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { config } from "dotenv";
2 | import yargs from "yargs";
3 | import { hideBin } from "yargs/helpers";
4 |
5 | // Load environment variables from .env file
6 | config();
7 |
8 | interface ServerConfig {
9 | figmaApiKey: string;
10 | port: number;
11 | configSources: {
12 | figmaApiKey: "cli" | "env";
13 | port: "cli" | "env" | "default";
14 | };
15 | }
16 |
17 | function maskApiKey(key: string): string {
18 | if (key.length <= 4) return "****";
19 | return `****${key.slice(-4)}`;
20 | }
21 |
22 | interface CliArgs {
23 | "figma-api-key"?: string;
24 | port?: number;
25 | }
26 |
27 | export function getServerConfig(isStdioMode: boolean): ServerConfig {
28 | // Parse command line arguments
29 | const argv = yargs(hideBin(process.argv))
30 | .options({
31 | "figma-api-key": {
32 | type: "string",
33 | description: "Figma API key",
34 | },
35 | port: {
36 | type: "number",
37 | description: "Port to run the server on",
38 | },
39 | })
40 | .help()
41 | .version("0.1.12")
42 | .parseSync() as CliArgs;
43 |
44 | const config: ServerConfig = {
45 | figmaApiKey: "",
46 | port: 3333,
47 | configSources: {
48 | figmaApiKey: "env",
49 | port: "default",
50 | },
51 | };
52 |
53 | // Handle FIGMA_API_KEY
54 | if (argv["figma-api-key"]) {
55 | config.figmaApiKey = argv["figma-api-key"];
56 | config.configSources.figmaApiKey = "cli";
57 | } else if (process.env.FIGMA_API_KEY) {
58 | config.figmaApiKey = process.env.FIGMA_API_KEY;
59 | config.configSources.figmaApiKey = "env";
60 | }
61 |
62 | // Handle PORT
63 | if (argv.port) {
64 | config.port = argv.port;
65 | config.configSources.port = "cli";
66 | } else if (process.env.PORT) {
67 | config.port = parseInt(process.env.PORT, 10);
68 | config.configSources.port = "env";
69 | }
70 |
71 | // Validate configuration
72 | if (!config.figmaApiKey) {
73 | console.error("FIGMA_API_KEY is required (via CLI argument --figma-api-key or .env file)");
74 | process.exit(1);
75 | }
76 |
77 | // Log configuration sources
78 | if (!isStdioMode) {
79 | console.log("\nConfiguration:");
80 | console.log(
81 | `- FIGMA_API_KEY: ${maskApiKey(config.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`,
82 | );
83 | console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`);
84 | console.log(); // Empty line for better readability
85 | }
86 |
87 | return config;
88 | }
89 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Bug Report
2 | description: Report a bug or unexpected behavior
3 | labels: ["bug", "triage"]
4 |
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to report a bug! Please fill out the form below.
10 |
11 | - type: textarea
12 | id: description
13 | attributes:
14 | label: Bug Description
15 | description: A clear and concise description of what the bug is.
16 | placeholder: Describe the bug...
17 | validations:
18 | required: true
19 |
20 | - type: textarea
21 | id: reproduction
22 | attributes:
23 | label: Steps to Reproduce
24 | description: Steps to reproduce the behavior.
25 | placeholder: |
26 | 1. Configure MCP server with...
27 | 2. Open Figma file...
28 | 3. Run command...
29 | 4. See error...
30 | validations:
31 | required: true
32 |
33 | - type: textarea
34 | id: expected
35 | attributes:
36 | label: Expected Behavior
37 | description: What did you expect to happen?
38 | placeholder: Describe what you expected...
39 | validations:
40 | required: true
41 |
42 | - type: textarea
43 | id: actual
44 | attributes:
45 | label: Actual Behavior
46 | description: What actually happened?
47 | placeholder: Describe what actually happened...
48 | validations:
49 | required: true
50 |
51 | - type: input
52 | id: version
53 | attributes:
54 | label: Package Version
55 | description: What version of figma-mcp-server are you using?
56 | placeholder: "1.0.0"
57 | validations:
58 | required: true
59 |
60 | - type: dropdown
61 | id: client
62 | attributes:
63 | label: AI Client
64 | description: Which AI client are you using?
65 | options:
66 | - Cursor
67 | - Windsurf
68 | - Cline
69 | - Claude Desktop
70 | - Other
71 | validations:
72 | required: true
73 |
74 | - type: input
75 | id: node-version
76 | attributes:
77 | label: Node.js Version
78 | description: What version of Node.js are you using? (run `node -v`)
79 | placeholder: "v20.0.0"
80 | validations:
81 | required: true
82 |
83 | - type: dropdown
84 | id: os
85 | attributes:
86 | label: Operating System
87 | options:
88 | - macOS
89 | - Windows
90 | - Linux
91 | validations:
92 | required: true
93 |
94 | - type: textarea
95 | id: logs
96 | attributes:
97 | label: Error Logs
98 | description: If applicable, paste any error logs here.
99 | render: shell
100 |
101 | - type: textarea
102 | id: additional
103 | attributes:
104 | label: Additional Context
105 | description: Any other context about the problem.
106 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.1.0] - 2025-01-06
9 |
10 | ### Added
11 |
12 | - **Grid Layout Detection** - Automatically detect and convert grid-like arrangements to CSS Grid
13 | - **Background Element Merging** - Smart merge of background layers with padding inference
14 | - **Multi-layer Cache System** - LRU memory cache (L1) + disk cache (L2) for 24h data persistence
15 | - **MCP Resources** - Lightweight resource endpoints (`figma://file`, `/styles`, `/components`, `/assets`) for token-efficient metadata browsing
16 | - **MCP Prompts** - Professional `design_to_code` prompt for guided AI code generation workflow
17 | - **Comprehensive Test Suite** - 272 tests covering layout optimization, icon detection, parser, and resources (Vitest)
18 |
19 | ### Changed
20 |
21 | - **Improved Flexbox Detection** - Enhanced stack detection with better gap/padding inference
22 | - **Icon Detection Optimization** - Single-pass tree traversal for better performance
23 | - **Modular Architecture** - Reorganized codebase (`transformers` → `core/algorithms`) for better maintainability
24 | - **Bilingual Documentation** - Complete English and Chinese docs for all algorithms and architecture
25 |
26 | ### Fixed
27 |
28 | - **Gradient Alpha Channel** - Preserve alpha values in gradient color stops
29 | - **Non-grid Element Positioning** - Correct position handling for elements outside grid containers
30 | - **Security Dependencies** - Updated dependencies to resolve vulnerabilities
31 |
32 | ## [1.0.1] - 2024-12-05
33 |
34 | ### Added
35 |
36 | - Smart layout detection algorithm (Flexbox inference from absolute positioning)
37 | - Icon layer merge algorithm (reduces fragmented exports by 96%)
38 | - CSS generation with optimized output
39 | - HTML preview generation from Figma JSON
40 | - Comprehensive documentation for algorithms
41 |
42 | ### Changed
43 |
44 | - Optimized data structures for AI consumption
45 | - Reduced output size by ~87% through intelligent simplification
46 | - Improved node processing with better type handling
47 |
48 | ### Fixed
49 |
50 | - Round all px values to integers
51 | - Proper handling of gradient and image fills
52 | - Border style extraction improvements
53 |
54 | ## [1.0.0] - 2024-12-01
55 |
56 | ### Added
57 |
58 | - Initial release
59 | - MCP server implementation for Figma integration
60 | - `get_figma_data` tool for fetching design data
61 | - `download_figma_images` tool for image export
62 | - Support for stdio and HTTP/SSE modes
63 | - Basic CSS style generation
64 | - Figma API integration with caching
65 |
66 | [1.1.0]: https://github.com/1yhy/Figma-Context-MCP/compare/v1.0.1...v1.1.0
67 | [1.0.1]: https://github.com/1yhy/Figma-Context-MCP/compare/v1.0.0...v1.0.1
68 | [1.0.0]: https://github.com/1yhy/Figma-Context-MCP/releases/tag/v1.0.0
69 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@yhy2001/figma-mcp-server",
3 | "version": "1.1.0",
4 | "description": "MCP server for Figma design integration with AI coding tools",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "bin": {
8 | "figma-mcp": "dist/index.js"
9 | },
10 | "files": [
11 | "dist",
12 | "README.md",
13 | "README.zh-CN.md"
14 | ],
15 | "scripts": {
16 | "dev": "cross-env NODE_ENV=development tsup --watch",
17 | "build": "tsup",
18 | "start": "node dist/index.js",
19 | "start:cli": "cross-env NODE_ENV=cli node dist/index.js",
20 | "start:http": "node dist/index.js",
21 | "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio",
22 | "type-check": "tsc --noEmit",
23 | "lint": "eslint src/",
24 | "lint:fix": "eslint src/ --fix",
25 | "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
26 | "format:check": "prettier --check \"src/**/*.ts\"",
27 | "test": "vitest run",
28 | "test:watch": "vitest",
29 | "test:coverage": "vitest run --coverage",
30 | "test:ui": "vitest --ui",
31 | "test:unit": "vitest run tests/unit",
32 | "test:integration": "vitest run tests/integration",
33 | "preview": "tsx tests/utils/preview.ts",
34 | "inspect": "pnpx @modelcontextprotocol/inspector",
35 | "mcp-test": "pnpm start -- --stdio",
36 | "prepublishOnly": "pnpm type-check && pnpm lint && pnpm test && pnpm build",
37 | "pub:release": "pnpm build && npm publish --access public",
38 | "publish:local": "pnpm build && npm pack",
39 | "prepare": "husky || true"
40 | },
41 | "engines": {
42 | "node": ">=18.0.0"
43 | },
44 | "repository": {
45 | "type": "git",
46 | "url": "git+https://github.com/1yhy/Figma-Context-MCP.git"
47 | },
48 | "homepage": "https://github.com/1yhy/Figma-Context-MCP#readme",
49 | "bugs": {
50 | "url": "https://github.com/1yhy/Figma-Context-MCP/issues"
51 | },
52 | "keywords": [
53 | "figma",
54 | "mcp",
55 | "model-context-protocol",
56 | "typescript",
57 | "ai",
58 | "design",
59 | "cursor",
60 | "windsurf",
61 | "cline",
62 | "design-to-code"
63 | ],
64 | "author": "1yhy",
65 | "license": "MIT",
66 | "dependencies": {
67 | "@modelcontextprotocol/sdk": "^1.24.3",
68 | "cross-env": "^7.0.3",
69 | "dotenv": "^16.4.7",
70 | "express": "^4.21.2",
71 | "remeda": "^2.20.1",
72 | "yargs": "^17.7.2",
73 | "zod": "^4.1.13"
74 | },
75 | "devDependencies": {
76 | "@commitlint/cli": "^20.2.0",
77 | "@commitlint/config-conventional": "^19.0.0",
78 | "@eslint/js": "^9.39.1",
79 | "@figma/rest-api-spec": "^0.24.0",
80 | "@types/express": "^5.0.0",
81 | "@types/node": "^24.10.1",
82 | "@types/yargs": "^17.0.33",
83 | "@typescript-eslint/eslint-plugin": "^8.48.1",
84 | "@typescript-eslint/parser": "^8.48.1",
85 | "@vitest/coverage-v8": "^4.0.15",
86 | "eslint": "^9.39.1",
87 | "eslint-config-prettier": "^10.0.1",
88 | "husky": "^9.0.0",
89 | "lint-staged": "^15.0.0",
90 | "prettier": "^3.5.0",
91 | "tsup": "^8.5.1",
92 | "tsx": "^4.21.0",
93 | "typescript": "^5.7.3",
94 | "typescript-eslint": "^8.48.1",
95 | "vitest": "^4.0.15"
96 | }
97 | }
98 |
```
--------------------------------------------------------------------------------
/src/utils/color.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Color Conversion Utilities
3 | *
4 | * Functions for converting between Figma color formats and CSS color values.
5 | *
6 | * @module utils/color
7 | */
8 |
9 | import type { Paint, RGBA } from "@figma/rest-api-spec";
10 | import type { CSSHexColor, CSSRGBAColor, SimplifiedFill } from "~/types/index.js";
11 |
12 | // ==================== Type Definitions ====================
13 |
14 | export interface ColorValue {
15 | hex: CSSHexColor;
16 | opacity: number;
17 | }
18 |
19 | // ==================== Color Conversion ====================
20 |
21 | /**
22 | * Convert hex color and opacity to rgba format
23 | */
24 | export function hexToRgba(hex: string, opacity: number = 1): string {
25 | hex = hex.replace("#", "");
26 |
27 | if (hex.length === 3) {
28 | hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
29 | }
30 |
31 | const r = parseInt(hex.substring(0, 2), 16);
32 | const g = parseInt(hex.substring(2, 4), 16);
33 | const b = parseInt(hex.substring(4, 6), 16);
34 | const validOpacity = Math.min(Math.max(opacity, 0), 1);
35 |
36 | return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
37 | }
38 |
39 | /**
40 | * Convert Figma RGBA color to { hex, opacity }
41 | */
42 | export function convertColor(color: RGBA, opacity = 1): ColorValue {
43 | const r = Math.round(color.r * 255);
44 | const g = Math.round(color.g * 255);
45 | const b = Math.round(color.b * 255);
46 | const a = Math.round(opacity * color.a * 100) / 100;
47 |
48 | const hex = ("#" +
49 | ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor;
50 |
51 | return { hex, opacity: a };
52 | }
53 |
54 | /**
55 | * Convert Figma RGBA to CSS rgba() format
56 | */
57 | export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
58 | const r = Math.round(color.r * 255);
59 | const g = Math.round(color.g * 255);
60 | const b = Math.round(color.b * 255);
61 | const a = Math.round(opacity * color.a * 100) / 100;
62 |
63 | return `rgba(${r}, ${g}, ${b}, ${a})`;
64 | }
65 |
66 | // ==================== Fill Parsing ====================
67 |
68 | /**
69 | * Convert Figma Paint to simplified fill format
70 | */
71 | export function parsePaint(raw: Paint): SimplifiedFill {
72 | if (raw.type === "IMAGE") {
73 | const imagePaint = raw as { type: "IMAGE"; imageRef?: string; scaleMode?: string };
74 | return {
75 | type: "IMAGE",
76 | imageRef: imagePaint.imageRef,
77 | scaleMode: imagePaint.scaleMode,
78 | };
79 | }
80 |
81 | if (raw.type === "SOLID") {
82 | const { hex, opacity } = convertColor(raw.color!, raw.opacity);
83 | if (opacity === 1) {
84 | return hex;
85 | }
86 | return formatRGBAColor(raw.color!, opacity);
87 | }
88 |
89 | if (
90 | raw.type === "GRADIENT_LINEAR" ||
91 | raw.type === "GRADIENT_RADIAL" ||
92 | raw.type === "GRADIENT_ANGULAR" ||
93 | raw.type === "GRADIENT_DIAMOND"
94 | ) {
95 | const gradientPaint = raw as {
96 | type: typeof raw.type;
97 | gradientHandlePositions?: Array<{ x: number; y: number }>;
98 | gradientStops?: Array<{ position: number; color: RGBA }>;
99 | };
100 | return {
101 | type: raw.type,
102 | gradientHandlePositions: gradientPaint.gradientHandlePositions,
103 | gradientStops: gradientPaint.gradientStops?.map(({ position, color }) => ({
104 | position,
105 | color: convertColor(color).hex,
106 | })),
107 | };
108 | }
109 |
110 | throw new Error(`Unknown paint type: ${raw.type}`);
111 | }
112 |
```
--------------------------------------------------------------------------------
/src/services/cache/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Cache System Type Definitions
3 | *
4 | * @module services/cache/types
5 | */
6 |
7 | // ==================== Configuration ====================
8 |
9 | /**
10 | * Memory cache configuration
11 | */
12 | export interface MemoryCacheConfig {
13 | /** Maximum number of node cache items (default: 100) */
14 | maxNodeItems: number;
15 | /** Maximum number of image cache items (default: 50) */
16 | maxImageItems: number;
17 | /** Node cache TTL in milliseconds (default: 5 minutes) */
18 | nodeTTL: number;
19 | /** Image cache TTL in milliseconds (default: 10 minutes) */
20 | imageTTL: number;
21 | }
22 |
23 | /**
24 | * Disk cache configuration
25 | */
26 | export interface DiskCacheConfig {
27 | /** Cache directory path */
28 | cacheDir: string;
29 | /** Maximum disk cache size in bytes (default: 500MB) */
30 | maxSize: number;
31 | /** Cache TTL in milliseconds (default: 24 hours) */
32 | ttl: number;
33 | }
34 |
35 | /**
36 | * Complete cache configuration
37 | */
38 | export interface CacheConfig {
39 | /** Whether caching is enabled */
40 | enabled: boolean;
41 | /** Memory cache configuration */
42 | memory: MemoryCacheConfig;
43 | /** Disk cache configuration */
44 | disk: DiskCacheConfig;
45 | }
46 |
47 | // ==================== Cache Entries ====================
48 |
49 | /**
50 | * Cache entry metadata
51 | */
52 | export interface CacheEntryMeta {
53 | /** Cache key */
54 | key: string;
55 | /** Creation timestamp */
56 | createdAt: number;
57 | /** Expiration timestamp */
58 | expiresAt: number;
59 | /** Figma file key */
60 | fileKey: string;
61 | /** Figma node ID (optional) */
62 | nodeId?: string;
63 | /** Figma file version (lastModified) */
64 | version?: string;
65 | /** Query depth */
66 | depth?: number;
67 | /** Data size in bytes */
68 | size?: number;
69 | }
70 |
71 | /**
72 | * Node cache entry
73 | */
74 | export interface NodeCacheEntry {
75 | /** Cached data */
76 | data: unknown;
77 | /** Figma file key */
78 | fileKey: string;
79 | /** Figma node ID */
80 | nodeId?: string;
81 | /** Figma file version */
82 | version?: string;
83 | /** Query depth */
84 | depth?: number;
85 | }
86 |
87 | /**
88 | * Image cache entry
89 | */
90 | export interface ImageCacheEntry {
91 | /** Local file path */
92 | path: string;
93 | /** Figma file key */
94 | fileKey: string;
95 | /** Figma node ID */
96 | nodeId: string;
97 | /** Image format */
98 | format: string;
99 | /** File size in bytes */
100 | size?: number;
101 | }
102 |
103 | // ==================== Statistics ====================
104 |
105 | /**
106 | * Memory cache statistics
107 | */
108 | export interface MemoryCacheStats {
109 | /** Cache hits */
110 | hits: number;
111 | /** Cache misses */
112 | misses: number;
113 | /** Current item count */
114 | size: number;
115 | /** Maximum item count */
116 | maxSize: number;
117 | /** Hit rate (0-1) */
118 | hitRate: number;
119 | /** Eviction count */
120 | evictions: number;
121 | }
122 |
123 | /**
124 | * Disk cache statistics
125 | */
126 | export interface DiskCacheStats {
127 | /** Cache hits */
128 | hits: number;
129 | /** Cache misses */
130 | misses: number;
131 | /** Total size in bytes */
132 | totalSize: number;
133 | /** Maximum size in bytes */
134 | maxSize: number;
135 | /** Node data file count */
136 | nodeFileCount: number;
137 | /** Image file count */
138 | imageFileCount: number;
139 | }
140 |
141 | /**
142 | * Combined cache statistics
143 | */
144 | export interface CacheStatistics {
145 | /** Whether cache is enabled */
146 | enabled: boolean;
147 | /** Memory cache stats */
148 | memory: MemoryCacheStats;
149 | /** Disk cache stats */
150 | disk: DiskCacheStats;
151 | }
152 |
153 | // ==================== Default Configurations ====================
154 |
155 | /**
156 | * Default memory cache configuration
157 | */
158 | export const DEFAULT_MEMORY_CONFIG: MemoryCacheConfig = {
159 | maxNodeItems: 100,
160 | maxImageItems: 50,
161 | nodeTTL: 5 * 60 * 1000, // 5 minutes
162 | imageTTL: 10 * 60 * 1000, // 10 minutes
163 | };
164 |
165 | /**
166 | * Default disk cache configuration
167 | */
168 | export const DEFAULT_DISK_CONFIG: Partial<DiskCacheConfig> = {
169 | maxSize: 500 * 1024 * 1024, // 500MB
170 | ttl: 24 * 60 * 60 * 1000, // 24 hours
171 | };
172 |
173 | /**
174 | * Default complete cache configuration
175 | */
176 | export const DEFAULT_CACHE_CONFIG: Omit<CacheConfig, "disk"> & { disk: Partial<DiskCacheConfig> } =
177 | {
178 | enabled: true,
179 | memory: DEFAULT_MEMORY_CONFIG,
180 | disk: DEFAULT_DISK_CONFIG,
181 | };
182 |
```
--------------------------------------------------------------------------------
/scripts/optimize-figma-json.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env npx tsx
2 | /**
3 | * Figma JSON Optimizer Script
4 | *
5 | * Usage:
6 | * npx tsx scripts/optimize-figma-json.ts <input-file> [output-file]
7 | *
8 | * Examples:
9 | * npx tsx scripts/optimize-figma-json.ts tests/fixtures/figma-data/node-108-517.json
10 | * npx tsx scripts/optimize-figma-json.ts input.json output-optimized.json
11 | *
12 | * If output-file is not specified:
13 | * - For files in figma-data/, outputs to expected/<name>-optimized.json
14 | * - Otherwise, outputs to <input-name>-optimized.json in the same directory
15 | */
16 |
17 | import fs from "fs";
18 | import path from "path";
19 | import { parseFigmaResponse } from "../src/core/parser.js";
20 |
21 | // Parse arguments
22 | const args = process.argv.slice(2);
23 |
24 | if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
25 | console.log(`
26 | Figma JSON Optimizer
27 |
28 | Usage:
29 | npx tsx scripts/optimize-figma-json.ts <input-file> [output-file]
30 |
31 | Examples:
32 | npx tsx scripts/optimize-figma-json.ts tests/fixtures/figma-data/node-108-517.json
33 | npx tsx scripts/optimize-figma-json.ts input.json output-optimized.json
34 |
35 | Options:
36 | --help, -h Show this help message
37 | --stats Show detailed statistics only (no file output)
38 | --quiet, -q Minimal output
39 | `);
40 | process.exit(0);
41 | }
42 |
43 | const inputFile = args[0];
44 | const showStatsOnly = args.includes("--stats");
45 | const quiet = args.includes("--quiet") || args.includes("-q");
46 |
47 | // Determine output file
48 | let outputFile = args.find((arg) => !arg.startsWith("-") && arg !== inputFile);
49 |
50 | if (!outputFile && !showStatsOnly) {
51 | const inputDir = path.dirname(inputFile);
52 | const inputName = path.basename(inputFile, ".json");
53 |
54 | // If input is in figma-data, output to expected
55 | if (inputDir.includes("figma-data")) {
56 | const expectedDir = inputDir.replace("figma-data", "expected");
57 | outputFile = path.join(expectedDir, `${inputName}-optimized.json`);
58 | } else {
59 | outputFile = path.join(inputDir, `${inputName}-optimized.json`);
60 | }
61 | }
62 |
63 | // Check input file exists
64 | if (!fs.existsSync(inputFile)) {
65 | console.error(`Error: Input file not found: ${inputFile}`);
66 | process.exit(1);
67 | }
68 |
69 | // Read and optimize
70 | if (!quiet) {
71 | console.log(`\nOptimizing: ${inputFile}`);
72 | }
73 |
74 | const startTime = Date.now();
75 | const rawData = JSON.parse(fs.readFileSync(inputFile, "utf8"));
76 | const optimized = parseFigmaResponse(rawData);
77 | const elapsed = Date.now() - startTime;
78 |
79 | // Calculate statistics
80 | const json = JSON.stringify(optimized, null, 2);
81 | const originalSize = fs.statSync(inputFile).size;
82 | const optimizedSize = Buffer.byteLength(json);
83 | const compression = ((1 - optimizedSize / originalSize) * 100).toFixed(1);
84 |
85 | const absoluteCount = (json.match(/"position":\s*"absolute"/g) || []).length;
86 | const gridCount = (json.match(/"display":\s*"grid"/g) || []).length;
87 | const flexCount = (json.match(/"display":\s*"flex"/g) || []).length;
88 | const flexRowCount = (json.match(/"flexDirection":\s*"row"/g) || []).length;
89 | const flexColumnCount = (json.match(/"flexDirection":\s*"column"/g) || []).length;
90 |
91 | // Get root node info
92 | let rootInfo = "";
93 | if (optimized.nodes && optimized.nodes.length > 0) {
94 | const root = optimized.nodes[0];
95 | rootInfo = `${root.name} (${root.cssStyles?.width} × ${root.cssStyles?.height})`;
96 | }
97 |
98 | // Output results
99 | if (!quiet) {
100 | console.log(`\n${"─".repeat(50)}`);
101 | console.log(`Root: ${rootInfo}`);
102 | console.log(`${"─".repeat(50)}`);
103 | console.log(`\nLayout Statistics:`);
104 | console.log(` position:absolute ${absoluteCount}`);
105 | console.log(` display:grid ${gridCount}`);
106 | console.log(` display:flex ${flexCount} (row: ${flexRowCount}, column: ${flexColumnCount})`);
107 | console.log(`\nCompression:`);
108 | console.log(` Original: ${(originalSize / 1024).toFixed(1)} KB`);
109 | console.log(` Optimized: ${(optimizedSize / 1024).toFixed(1)} KB`);
110 | console.log(` Reduced: ${compression}%`);
111 | console.log(`\nTime: ${elapsed}ms`);
112 | }
113 |
114 | // Save output
115 | if (!showStatsOnly && outputFile) {
116 | // Ensure output directory exists
117 | const outputDir = path.dirname(outputFile);
118 | if (!fs.existsSync(outputDir)) {
119 | fs.mkdirSync(outputDir, { recursive: true });
120 | }
121 |
122 | fs.writeFileSync(outputFile, json);
123 |
124 | if (!quiet) {
125 | console.log(`\nSaved: ${outputFile}`);
126 | } else {
127 | console.log(outputFile);
128 | }
129 | }
130 |
131 | if (!quiet) {
132 | console.log("");
133 | }
134 |
```
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Validation Utilities
3 | *
4 | * Type guards, validators, and visibility checks for Figma nodes.
5 | *
6 | * @module utils/validation
7 | */
8 |
9 | import type {
10 | Rectangle,
11 | HasLayoutTrait,
12 | StrokeWeights,
13 | HasFramePropertiesTrait,
14 | } from "@figma/rest-api-spec";
15 | import { isTruthy } from "remeda";
16 | import type { CSSHexColor, CSSRGBAColor } from "~/types/index.js";
17 |
18 | export { isTruthy };
19 |
20 | // ==================== Visibility Types ====================
21 |
22 | /** Properties for visibility checking */
23 | export interface VisibilityProperties {
24 | visible?: boolean;
25 | opacity?: number;
26 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
27 | absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null;
28 | }
29 |
30 | /** Properties for parent container clipping check */
31 | export interface ParentClipProperties {
32 | clipsContent?: boolean;
33 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
34 | }
35 |
36 | // ==================== Visibility Checks ====================
37 |
38 | /**
39 | * Check if an element is visible
40 | */
41 | export function isVisible(element: VisibilityProperties): boolean {
42 | if (element.visible === false) {
43 | return false;
44 | }
45 |
46 | if (element.opacity === 0) {
47 | return false;
48 | }
49 |
50 | if (element.absoluteRenderBounds === null) {
51 | return false;
52 | }
53 |
54 | return true;
55 | }
56 |
57 | /**
58 | * Check if an element is visible within its parent container (considering clipping)
59 | */
60 | export function isVisibleInParent(
61 | element: VisibilityProperties,
62 | parent: ParentClipProperties,
63 | ): boolean {
64 | if (!isVisible(element)) {
65 | return false;
66 | }
67 |
68 | if (
69 | parent &&
70 | parent.clipsContent === true &&
71 | element.absoluteBoundingBox &&
72 | parent.absoluteBoundingBox
73 | ) {
74 | const elementBox = element.absoluteBoundingBox;
75 | const parentBox = parent.absoluteBoundingBox;
76 |
77 | const outsideParent =
78 | elementBox.x >= parentBox.x + parentBox.width ||
79 | elementBox.x + elementBox.width <= parentBox.x ||
80 | elementBox.y >= parentBox.y + parentBox.height ||
81 | elementBox.y + elementBox.height <= parentBox.y;
82 |
83 | if (outsideParent) {
84 | return false;
85 | }
86 | }
87 |
88 | return true;
89 | }
90 |
91 | // ==================== Object Processing ====================
92 |
93 | /**
94 | * Remove empty arrays and empty objects from an object
95 | */
96 | export function removeEmptyKeys<T>(input: T): T {
97 | if (typeof input !== "object" || input === null) {
98 | return input;
99 | }
100 |
101 | if (Array.isArray(input)) {
102 | return input.map((item) => removeEmptyKeys(item)) as T;
103 | }
104 |
105 | const result = {} as T;
106 | for (const key in input) {
107 | if (Object.prototype.hasOwnProperty.call(input, key)) {
108 | const value = input[key];
109 | const cleanedValue = removeEmptyKeys(value);
110 |
111 | if (
112 | cleanedValue !== undefined &&
113 | !(Array.isArray(cleanedValue) && cleanedValue.length === 0) &&
114 | !(
115 | typeof cleanedValue === "object" &&
116 | cleanedValue !== null &&
117 | Object.keys(cleanedValue).length === 0
118 | )
119 | ) {
120 | result[key] = cleanedValue;
121 | }
122 | }
123 | }
124 |
125 | return result;
126 | }
127 |
128 | // ==================== Type Guards ====================
129 |
130 | export function hasValue<K extends PropertyKey, T>(
131 | key: K,
132 | obj: unknown,
133 | typeGuard?: (val: unknown) => val is T,
134 | ): obj is Record<K, T> {
135 | const isObject = typeof obj === "object" && obj !== null;
136 | if (!isObject || !(key in obj)) return false;
137 | const val = (obj as Record<K, unknown>)[key];
138 | return typeGuard ? typeGuard(val) : val !== undefined;
139 | }
140 |
141 | export function isFrame(val: unknown): val is HasFramePropertiesTrait {
142 | return (
143 | typeof val === "object" &&
144 | !!val &&
145 | "clipsContent" in val &&
146 | typeof val.clipsContent === "boolean"
147 | );
148 | }
149 |
150 | export function isLayout(val: unknown): val is HasLayoutTrait {
151 | return (
152 | typeof val === "object" &&
153 | !!val &&
154 | "absoluteBoundingBox" in val &&
155 | typeof val.absoluteBoundingBox === "object" &&
156 | !!val.absoluteBoundingBox &&
157 | "x" in val.absoluteBoundingBox &&
158 | "y" in val.absoluteBoundingBox &&
159 | "width" in val.absoluteBoundingBox &&
160 | "height" in val.absoluteBoundingBox
161 | );
162 | }
163 |
164 | export function isStrokeWeights(val: unknown): val is StrokeWeights {
165 | return (
166 | typeof val === "object" &&
167 | val !== null &&
168 | "top" in val &&
169 | "right" in val &&
170 | "bottom" in val &&
171 | "left" in val
172 | );
173 | }
174 |
175 | export function isRectangle<T, K extends string>(
176 | key: K,
177 | obj: T,
178 | ): obj is T & { [P in K]: Rectangle } {
179 | const recordObj = obj as Record<K, unknown>;
180 | return (
181 | typeof obj === "object" &&
182 | !!obj &&
183 | key in recordObj &&
184 | typeof recordObj[key] === "object" &&
185 | !!recordObj[key] &&
186 | "x" in recordObj[key] &&
187 | "y" in recordObj[key] &&
188 | "width" in recordObj[key] &&
189 | "height" in recordObj[key]
190 | );
191 | }
192 |
193 | export function isRectangleCornerRadii(val: unknown): val is number[] {
194 | return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number");
195 | }
196 |
197 | export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor {
198 | return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba"));
199 | }
200 |
```
--------------------------------------------------------------------------------
/src/types/simplified.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Simplified Output Type Definitions
3 | *
4 | * Types for the MCP simplified output format, including CSS styles,
5 | * simplified node structures, and algorithm configurations.
6 | *
7 | * @module types/simplified
8 | */
9 |
10 | import type { ExportInfo } from "./figma.js";
11 |
12 | // ==================== CSS Types ====================
13 |
14 | /** CSS hex color format */
15 | export type CSSHexColor = `#${string}`;
16 |
17 | /** CSS rgba color format */
18 | export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
19 |
20 | /**
21 | * CSS style object containing all supported CSS properties
22 | */
23 | export type CSSStyle = {
24 | // Text styles
25 | fontFamily?: string;
26 | fontSize?: string;
27 | fontWeight?: string | number;
28 | textAlign?: string;
29 | verticalAlign?: string;
30 | lineHeight?: string;
31 |
32 | // Colors and backgrounds
33 | color?: string;
34 | backgroundColor?: string;
35 | background?: string;
36 | backgroundImage?: string;
37 |
38 | // Layout
39 | width?: string;
40 | height?: string;
41 | margin?: string;
42 | padding?: string;
43 | position?: string;
44 | top?: string;
45 | right?: string;
46 | bottom?: string;
47 | left?: string;
48 | display?: string;
49 | flexDirection?: string;
50 | justifyContent?: string;
51 | alignItems?: string;
52 | gap?: string;
53 |
54 | // Grid layout
55 | gridTemplateColumns?: string;
56 | gridTemplateRows?: string;
57 | rowGap?: string;
58 | columnGap?: string;
59 | justifyItems?: string;
60 | gridColumn?: string;
61 | gridRow?: string;
62 |
63 | // Flex item
64 | flexGrow?: string | number;
65 | flexShrink?: string | number;
66 | flexBasis?: string;
67 | flex?: string;
68 | alignSelf?: string;
69 | order?: string | number;
70 |
71 | // Borders and radius
72 | border?: string;
73 | borderRadius?: string;
74 | borderWidth?: string;
75 | borderStyle?: string;
76 | borderColor?: string;
77 | borderImage?: string;
78 | borderImageSlice?: string;
79 |
80 | // Effects
81 | boxShadow?: string;
82 | filter?: string;
83 | backdropFilter?: string;
84 | opacity?: string;
85 |
86 | // Webkit specific
87 | webkitBackgroundClip?: string;
88 | webkitTextFillColor?: string;
89 | backgroundClip?: string;
90 |
91 | // Allow additional properties
92 | [key: string]: string | number | undefined;
93 | };
94 |
95 | /**
96 | * Text style properties (legacy, for backward compatibility)
97 | */
98 | export type TextStyle = Partial<{
99 | fontFamily: string;
100 | fontWeight: number;
101 | fontSize: number;
102 | textAlignHorizontal: string;
103 | textAlignVertical: string;
104 | lineHeightPx: number;
105 | }>;
106 |
107 | // ==================== Simplified Node Types ====================
108 |
109 | /**
110 | * Fill type for simplified nodes
111 | */
112 | export type SimplifiedFill =
113 | | CSSHexColor
114 | | CSSRGBAColor
115 | | SimplifiedSolidFill
116 | | SimplifiedGradientFill
117 | | SimplifiedImageFill;
118 |
119 | /** Solid fill with explicit type */
120 | export interface SimplifiedSolidFill {
121 | type: "SOLID";
122 | color: string;
123 | opacity?: number;
124 | }
125 |
126 | /** Gradient fill */
127 | export interface SimplifiedGradientFill {
128 | type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND";
129 | gradientHandlePositions?: Array<{ x: number; y: number }>;
130 | gradientStops?: Array<{
131 | position: number;
132 | color: string;
133 | }>;
134 | }
135 |
136 | /** Image fill */
137 | export interface SimplifiedImageFill {
138 | type: "IMAGE";
139 | imageRef?: string;
140 | scaleMode?: string;
141 | }
142 |
143 | /**
144 | * Simplified node structure
145 | * This is the main output type for the MCP response
146 | */
147 | export interface SimplifiedNode {
148 | /** Node ID */
149 | id: string;
150 | /** Node name */
151 | name: string;
152 | /** Node type (FRAME, TEXT, VECTOR, etc.) */
153 | type: string;
154 | /** Text content (for TEXT nodes) */
155 | text?: string;
156 | /** Legacy text style (for backward compatibility) */
157 | style?: TextStyle;
158 | /** CSS styles */
159 | cssStyles?: CSSStyle;
160 | /** Fill information */
161 | fills?: SimplifiedFill[];
162 | /** Export information (for image nodes) */
163 | exportInfo?: ExportInfo;
164 | /** Child nodes */
165 | children?: SimplifiedNode[];
166 | /** Internal: absolute X coordinate */
167 | _absoluteX?: number;
168 | /** Internal: absolute Y coordinate */
169 | _absoluteY?: number;
170 | }
171 |
172 | /**
173 | * Simplified design output
174 | * Top-level structure returned by the MCP
175 | */
176 | export interface SimplifiedDesign {
177 | /** Design file name */
178 | name: string;
179 | /** Last modified timestamp */
180 | lastModified: string;
181 | /** File version */
182 | version?: string;
183 | /** Thumbnail URL */
184 | thumbnailUrl: string;
185 | /** Root nodes */
186 | nodes: SimplifiedNode[];
187 | }
188 |
189 | // ==================== Algorithm Types ====================
190 |
191 | /**
192 | * Icon detection result
193 | */
194 | export interface IconDetectionResult {
195 | nodeId: string;
196 | nodeName: string;
197 | shouldMerge: boolean;
198 | exportFormat: "SVG" | "PNG";
199 | reason: string;
200 | size?: { width: number; height: number };
201 | childCount?: number;
202 | }
203 |
204 | /**
205 | * Icon detection configuration
206 | */
207 | export interface IconDetectionConfig {
208 | /** Maximum icon size in pixels */
209 | maxIconSize: number;
210 | /** Minimum icon size in pixels */
211 | minIconSize: number;
212 | /** Minimum ratio of mergeable types */
213 | mergeableRatio: number;
214 | /** Maximum nesting depth */
215 | maxDepth: number;
216 | /** Maximum child count */
217 | maxChildren: number;
218 | /** Maximum size to respect exportSettings */
219 | respectExportSettingsMaxSize: number;
220 | }
221 |
222 | /**
223 | * Layout detection result
224 | */
225 | export interface LayoutInfo {
226 | type: "flex" | "absolute" | "grid";
227 | direction?: "row" | "column";
228 | gap?: number;
229 | justifyContent?: string;
230 | alignItems?: string;
231 | /** Grid-specific: row gap */
232 | rowGap?: number;
233 | /** Grid-specific: column gap */
234 | columnGap?: number;
235 | /** Grid-specific: template columns (e.g., "100px 200px 100px") */
236 | gridTemplateColumns?: string;
237 | /** Grid-specific: template rows (e.g., "auto auto") */
238 | gridTemplateRows?: string;
239 | /** Grid confidence score (0-1) */
240 | confidence?: number;
241 | }
242 |
```
--------------------------------------------------------------------------------
/src/utils/css.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * CSS Utilities
3 | *
4 | * CSS output optimization and generation utilities.
5 | * Used to reduce output size and improve readability.
6 | *
7 | * @module utils/css
8 | */
9 |
10 | // ==================== CSS Shorthand Generation ====================
11 |
12 | /**
13 | * Generate CSS shorthand properties (such as padding, margin, border-radius)
14 | *
15 | * @example
16 | * generateCSSShorthand({ top: 10, right: 10, bottom: 10, left: 10 }) // "10px"
17 | * generateCSSShorthand({ top: 10, right: 20, bottom: 10, left: 20 }) // "10px 20px"
18 | */
19 | export function generateCSSShorthand(
20 | values: {
21 | top: number;
22 | right: number;
23 | bottom: number;
24 | left: number;
25 | },
26 | options: {
27 | ignoreZero?: boolean;
28 | suffix?: string;
29 | } = {},
30 | ): string | undefined {
31 | const { ignoreZero = true, suffix = "px" } = options;
32 | const { top, right, bottom, left } = values;
33 |
34 | if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) {
35 | return undefined;
36 | }
37 |
38 | if (top === right && right === bottom && bottom === left) {
39 | return `${top}${suffix}`;
40 | }
41 |
42 | if (right === left) {
43 | if (top === bottom) {
44 | return `${top}${suffix} ${right}${suffix}`;
45 | }
46 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`;
47 | }
48 |
49 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`;
50 | }
51 |
52 | // ==================== Numeric Precision Optimization ====================
53 |
54 | /**
55 | * Round a number to specified precision
56 | * @param value Original number
57 | * @param precision Number of decimal places, default 0 (integer)
58 | */
59 | export function roundValue(value: number, precision: number = 0): number {
60 | if (precision === 0) {
61 | return Math.round(value);
62 | }
63 | const multiplier = Math.pow(10, precision);
64 | return Math.round(value * multiplier) / multiplier;
65 | }
66 |
67 | /**
68 | * Format px value, rounded to integer
69 | * @param value Pixel value
70 | */
71 | export function formatPxValue(value: number): string {
72 | return `${Math.round(value)}px`;
73 | }
74 |
75 | /**
76 | * Format numeric value, used for gap and other properties, rounded to integer
77 | * @param value Numeric value
78 | */
79 | export function formatNumericValue(value: number): string {
80 | return `${Math.round(value)}px`;
81 | }
82 |
83 | // ==================== Browser Defaults ====================
84 |
85 | /**
86 | * Browser/Tailwind default values
87 | * These values can be omitted from output
88 | */
89 | export const BROWSER_DEFAULTS: Record<string, string | number | undefined> = {
90 | // Text defaults
91 | textAlign: "left",
92 | verticalAlign: "top",
93 | fontWeight: 400,
94 |
95 | // Flex defaults
96 | flexDirection: "row",
97 | justifyContent: "flex-start",
98 | alignItems: "stretch",
99 |
100 | // Position defaults (if all elements are absolute, can be omitted)
101 | // position: 'static', // Not omitting for now, as we explicitly use absolute
102 |
103 | // Other
104 | opacity: "1",
105 | borderStyle: "none",
106 | };
107 |
108 | /**
109 | * Check if a value is the default value
110 | */
111 | export function isDefaultValue(key: string, value: string | number | undefined): boolean {
112 | if (value === undefined) return true;
113 |
114 | const defaultValue = BROWSER_DEFAULTS[key];
115 | if (defaultValue === undefined) return false;
116 |
117 | // Handle number and string comparison
118 | if (typeof defaultValue === "number" && typeof value === "number") {
119 | return defaultValue === value;
120 | }
121 |
122 | return String(defaultValue) === String(value);
123 | }
124 |
125 | /**
126 | * Omit default style values
127 | * @param styles CSS style object
128 | * @returns Optimized style object
129 | */
130 | export function omitDefaultStyles<T extends Record<string, unknown>>(styles: T): Partial<T> {
131 | const result: Partial<T> = {};
132 |
133 | for (const [key, value] of Object.entries(styles)) {
134 | // Skip undefined
135 | if (value === undefined) continue;
136 |
137 | // Skip default values
138 | if (isDefaultValue(key, value as string | number)) continue;
139 |
140 | // Keep non-default values
141 | (result as Record<string, unknown>)[key] = value;
142 | }
143 |
144 | return result;
145 | }
146 |
147 | // ==================== Gap Analysis ====================
148 |
149 | /**
150 | * Analyze gap consistency
151 | * @param gaps Array of gaps
152 | * @param tolerancePercent Tolerance percentage, default 20%
153 | */
154 | export function analyzeGapConsistency(
155 | gaps: number[],
156 | tolerancePercent: number = 20,
157 | ): {
158 | isConsistent: boolean;
159 | averageGap: number;
160 | roundedGap: number;
161 | variance: number;
162 | } {
163 | if (gaps.length === 0) {
164 | return { isConsistent: true, averageGap: 0, roundedGap: 0, variance: 0 };
165 | }
166 |
167 | if (gaps.length === 1) {
168 | const rounded = roundValue(gaps[0]);
169 | return { isConsistent: true, averageGap: gaps[0], roundedGap: rounded, variance: 0 };
170 | }
171 |
172 | // Calculate average
173 | const avg = gaps.reduce((a, b) => a + b, 0) / gaps.length;
174 |
175 | // Calculate variance
176 | const variance = gaps.reduce((sum, gap) => sum + Math.pow(gap - avg, 2), 0) / gaps.length;
177 | const stdDev = Math.sqrt(variance);
178 |
179 | // Determine consistency: standard deviation less than specified percentage of average
180 | const tolerance = avg * (tolerancePercent / 100);
181 | const isConsistent = stdDev <= tolerance;
182 |
183 | // Round to integer
184 | const roundedGap = roundValue(avg);
185 |
186 | return { isConsistent, averageGap: avg, roundedGap, variance };
187 | }
188 |
189 | /**
190 | * Round gap to common values
191 | * Common values: 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64
192 | */
193 | export function roundToCommonGap(gap: number): number {
194 | const COMMON_GAPS = [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128];
195 |
196 | // Find closest common value
197 | let closest = COMMON_GAPS[0];
198 | let minDiff = Math.abs(gap - closest);
199 |
200 | for (const commonGap of COMMON_GAPS) {
201 | const diff = Math.abs(gap - commonGap);
202 | if (diff < minDiff) {
203 | minDiff = diff;
204 | closest = commonGap;
205 | }
206 | }
207 |
208 | // If difference is too large (over 4px), use rounded value
209 | if (minDiff > 4) {
210 | return roundValue(gap);
211 | }
212 |
213 | return closest;
214 | }
215 |
216 | // ==================== Export Info Optimization ====================
217 |
218 | /**
219 | * Optimize exportInfo, omit nodeId if it's the same as node id
220 | */
221 | export function optimizeExportInfo(
222 | nodeId: string,
223 | exportInfo: { type: string; format: string; nodeId?: string; fileName?: string },
224 | ): { type: string; format: string; nodeId?: string; fileName?: string } {
225 | const result = { ...exportInfo };
226 |
227 | // If nodeId is the same as node id, omit it
228 | if (result.nodeId === nodeId) {
229 | delete result.nodeId;
230 | }
231 |
232 | return result;
233 | }
234 |
```
--------------------------------------------------------------------------------
/src/services/cache/lru-cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * LRU (Least Recently Used) Memory Cache
3 | *
4 | * A generic in-memory cache with LRU eviction policy.
5 | * Used as a fast cache layer before disk cache.
6 | *
7 | * Features:
8 | * - O(1) get/set operations
9 | * - Automatic eviction of least recently used items
10 | * - Optional TTL (time-to-live) per item
11 | * - Size limiting by item count
12 | * - Statistics tracking
13 | *
14 | * @module services/cache/lru-cache
15 | */
16 |
17 | import type { NodeCacheEntry } from "./types.js";
18 |
19 | export interface LRUCacheConfig {
20 | /** Maximum number of items in cache */
21 | maxSize: number;
22 | /** Default TTL in milliseconds (0 = no expiration) */
23 | defaultTTL: number;
24 | }
25 |
26 | interface CacheEntry<T> {
27 | value: T;
28 | createdAt: number;
29 | expiresAt: number | null;
30 | size?: number;
31 | }
32 |
33 | export interface CacheStats {
34 | hits: number;
35 | misses: number;
36 | evictions: number;
37 | size: number;
38 | maxSize: number;
39 | }
40 |
41 | const DEFAULT_CONFIG: LRUCacheConfig = {
42 | maxSize: 100,
43 | defaultTTL: 0, // No expiration by default
44 | };
45 |
46 | /**
47 | * Generic LRU Cache implementation using Map
48 | * Map maintains insertion order, making it ideal for LRU
49 | */
50 | export class LRUCache<T> {
51 | private cache: Map<string, CacheEntry<T>>;
52 | private config: LRUCacheConfig;
53 | private stats: CacheStats;
54 |
55 | constructor(config: Partial<LRUCacheConfig> = {}) {
56 | this.config = { ...DEFAULT_CONFIG, ...config };
57 | this.cache = new Map();
58 | this.stats = {
59 | hits: 0,
60 | misses: 0,
61 | evictions: 0,
62 | size: 0,
63 | maxSize: this.config.maxSize,
64 | };
65 | }
66 |
67 | /**
68 | * Get an item from cache
69 | * Returns null if not found or expired
70 | */
71 | get(key: string): T | null {
72 | const entry = this.cache.get(key);
73 |
74 | if (!entry) {
75 | this.stats.misses++;
76 | return null;
77 | }
78 |
79 | // Check expiration
80 | if (entry.expiresAt && Date.now() > entry.expiresAt) {
81 | this.delete(key);
82 | this.stats.misses++;
83 | return null;
84 | }
85 |
86 | // Move to end (most recently used)
87 | this.cache.delete(key);
88 | this.cache.set(key, entry);
89 |
90 | this.stats.hits++;
91 | return entry.value;
92 | }
93 |
94 | /**
95 | * Set an item in cache
96 | * @param key Cache key
97 | * @param value Value to cache
98 | * @param ttl Optional TTL in milliseconds (overrides default)
99 | */
100 | set(key: string, value: T, ttl?: number): void {
101 | // If key exists, delete it first (to update position)
102 | if (this.cache.has(key)) {
103 | this.cache.delete(key);
104 | } else {
105 | // Evict if at capacity
106 | while (this.cache.size >= this.config.maxSize) {
107 | this.evictOldest();
108 | }
109 | }
110 |
111 | const effectiveTTL = ttl ?? this.config.defaultTTL;
112 | const entry: CacheEntry<T> = {
113 | value,
114 | createdAt: Date.now(),
115 | expiresAt: effectiveTTL > 0 ? Date.now() + effectiveTTL : null,
116 | };
117 |
118 | this.cache.set(key, entry);
119 | this.stats.size = this.cache.size;
120 | }
121 |
122 | /**
123 | * Check if key exists (without updating access time)
124 | */
125 | has(key: string): boolean {
126 | const entry = this.cache.get(key);
127 | if (!entry) return false;
128 |
129 | // Check expiration
130 | if (entry.expiresAt && Date.now() > entry.expiresAt) {
131 | this.delete(key);
132 | return false;
133 | }
134 |
135 | return true;
136 | }
137 |
138 | /**
139 | * Delete an item from cache
140 | */
141 | delete(key: string): boolean {
142 | const existed = this.cache.delete(key);
143 | if (existed) {
144 | this.stats.size = this.cache.size;
145 | }
146 | return existed;
147 | }
148 |
149 | /**
150 | * Clear all items from cache
151 | */
152 | clear(): void {
153 | this.cache.clear();
154 | this.stats.size = 0;
155 | }
156 |
157 | /**
158 | * Get all keys in cache (most recent last)
159 | */
160 | keys(): string[] {
161 | return Array.from(this.cache.keys());
162 | }
163 |
164 | /**
165 | * Get cache size
166 | */
167 | get size(): number {
168 | return this.cache.size;
169 | }
170 |
171 | /**
172 | * Get cache statistics
173 | */
174 | getStats(): CacheStats {
175 | return { ...this.stats };
176 | }
177 |
178 | /**
179 | * Reset statistics
180 | */
181 | resetStats(): void {
182 | this.stats.hits = 0;
183 | this.stats.misses = 0;
184 | this.stats.evictions = 0;
185 | }
186 |
187 | /**
188 | * Get hit rate (0-1)
189 | */
190 | getHitRate(): number {
191 | const total = this.stats.hits + this.stats.misses;
192 | return total === 0 ? 0 : this.stats.hits / total;
193 | }
194 |
195 | /**
196 | * Clean expired entries
197 | */
198 | cleanExpired(): number {
199 | const now = Date.now();
200 | let cleaned = 0;
201 |
202 | for (const [key, entry] of this.cache.entries()) {
203 | if (entry.expiresAt && now > entry.expiresAt) {
204 | this.cache.delete(key);
205 | cleaned++;
206 | }
207 | }
208 |
209 | this.stats.size = this.cache.size;
210 | return cleaned;
211 | }
212 |
213 | /**
214 | * Evict the oldest (least recently used) item
215 | */
216 | private evictOldest(): void {
217 | const oldestKey = this.cache.keys().next().value;
218 | if (oldestKey !== undefined) {
219 | this.cache.delete(oldestKey);
220 | this.stats.evictions++;
221 | this.stats.size = this.cache.size;
222 | }
223 | }
224 |
225 | /**
226 | * Peek at an item without updating access time
227 | */
228 | peek(key: string): T | null {
229 | const entry = this.cache.get(key);
230 | if (!entry) return null;
231 |
232 | if (entry.expiresAt && Date.now() > entry.expiresAt) {
233 | return null;
234 | }
235 |
236 | return entry.value;
237 | }
238 |
239 | /**
240 | * Update config (applies to new entries only)
241 | */
242 | updateConfig(config: Partial<LRUCacheConfig>): void {
243 | this.config = { ...this.config, ...config };
244 | this.stats.maxSize = this.config.maxSize;
245 |
246 | // Evict if new maxSize is smaller
247 | while (this.cache.size > this.config.maxSize) {
248 | this.evictOldest();
249 | }
250 | }
251 | }
252 |
253 | /**
254 | * Specialized LRU cache for Figma node data
255 | * Includes version-aware caching
256 | */
257 | export class NodeLRUCache extends LRUCache<NodeCacheEntry> {
258 | /**
259 | * Generate cache key for node data
260 | */
261 | static generateKey(fileKey: string, nodeId?: string, depth?: number): string {
262 | const parts = [fileKey];
263 | if (nodeId) parts.push(`n:${nodeId}`);
264 | if (depth !== undefined) parts.push(`d:${depth}`);
265 | return parts.join(":");
266 | }
267 |
268 | /**
269 | * Get node data with version check
270 | */
271 | getNode(fileKey: string, nodeId?: string, depth?: number, version?: string): unknown | null {
272 | const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
273 | const entry = this.get(key);
274 |
275 | if (!entry) return null;
276 |
277 | // Version mismatch - cache is stale
278 | if (version && entry.version && entry.version !== version) {
279 | this.delete(key);
280 | return null;
281 | }
282 |
283 | return entry.data;
284 | }
285 |
286 | /**
287 | * Set node data with metadata
288 | */
289 | setNode(
290 | data: unknown,
291 | fileKey: string,
292 | nodeId?: string,
293 | depth?: number,
294 | version?: string,
295 | ttl?: number,
296 | ): void {
297 | const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
298 | const entry: NodeCacheEntry = {
299 | data,
300 | fileKey,
301 | nodeId,
302 | version,
303 | depth,
304 | };
305 | this.set(key, entry, ttl);
306 | }
307 |
308 | /**
309 | * Invalidate all cache entries for a file
310 | */
311 | invalidateFile(fileKey: string): number {
312 | let invalidated = 0;
313 | for (const key of this.keys()) {
314 | if (key.startsWith(fileKey)) {
315 | this.delete(key);
316 | invalidated++;
317 | }
318 | }
319 | return invalidated;
320 | }
321 |
322 | /**
323 | * Invalidate cache entries for a specific node and its descendants
324 | */
325 | invalidateNode(fileKey: string, nodeId: string): number {
326 | let invalidated = 0;
327 | const prefix = NodeLRUCache.generateKey(fileKey, nodeId);
328 | for (const key of this.keys()) {
329 | if (key.startsWith(prefix)) {
330 | this.delete(key);
331 | invalidated++;
332 | }
333 | }
334 | return invalidated;
335 | }
336 | }
337 |
```
--------------------------------------------------------------------------------
/src/services/cache/cache-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Unified Cache Manager
3 | *
4 | * Manages multi-layer caching with L1 (memory) and L2 (disk) layers.
5 | *
6 | * Cache hierarchy:
7 | * - L1: In-memory LRU cache (fast, limited size)
8 | * - L2: Disk-based cache (persistent, larger capacity)
9 | *
10 | * @module services/cache/cache-manager
11 | */
12 |
13 | import type { CacheConfig, CacheStatistics } from "./types.js";
14 | import { DEFAULT_MEMORY_CONFIG } from "./types.js";
15 | import { NodeLRUCache } from "./lru-cache.js";
16 | import { DiskCache } from "./disk-cache.js";
17 | import os from "os";
18 | import path from "path";
19 |
20 | /**
21 | * Default cache configuration
22 | */
23 | const DEFAULT_CONFIG: CacheConfig = {
24 | enabled: true,
25 | memory: DEFAULT_MEMORY_CONFIG,
26 | disk: {
27 | cacheDir: path.join(os.homedir(), ".figma-mcp-cache"),
28 | maxSize: 500 * 1024 * 1024, // 500MB
29 | ttl: 24 * 60 * 60 * 1000, // 24 hours
30 | },
31 | };
32 |
33 | /**
34 | * Unified cache manager with multi-layer caching
35 | */
36 | export class CacheManager {
37 | private config: CacheConfig;
38 | private memoryCache: NodeLRUCache;
39 | private diskCache: DiskCache | null;
40 |
41 | constructor(config: Partial<CacheConfig> = {}) {
42 | this.config = this.mergeConfig(DEFAULT_CONFIG, config);
43 |
44 | // Skip initialization if disabled
45 | if (!this.config.enabled) {
46 | this.memoryCache = new NodeLRUCache({ maxSize: 0, defaultTTL: 0 });
47 | this.diskCache = null;
48 | return;
49 | }
50 |
51 | // Initialize L1: Memory cache
52 | this.memoryCache = new NodeLRUCache({
53 | maxSize: this.config.memory.maxNodeItems,
54 | defaultTTL: this.config.memory.nodeTTL,
55 | });
56 |
57 | // Initialize L2: Disk cache
58 | this.diskCache = new DiskCache(this.config.disk);
59 | }
60 |
61 | /**
62 | * Deep merge configuration
63 | */
64 | private mergeConfig(defaults: CacheConfig, overrides: Partial<CacheConfig>): CacheConfig {
65 | return {
66 | enabled: overrides.enabled ?? defaults.enabled,
67 | memory: {
68 | ...defaults.memory,
69 | ...overrides.memory,
70 | },
71 | disk: {
72 | ...defaults.disk,
73 | ...overrides.disk,
74 | },
75 | };
76 | }
77 |
78 | // ==================== Node Data Operations ====================
79 |
80 | /**
81 | * Get node data with multi-layer cache lookup
82 | *
83 | * Flow: L1 (memory) -> L2 (disk) -> null (cache miss)
84 | */
85 | async getNodeData<T>(
86 | fileKey: string,
87 | nodeId?: string,
88 | depth?: number,
89 | version?: string,
90 | ): Promise<T | null> {
91 | if (!this.config.enabled) return null;
92 |
93 | // L1: Check memory cache
94 | const memoryData = this.memoryCache.getNode(fileKey, nodeId, depth, version);
95 | if (memoryData !== null) {
96 | return memoryData as T;
97 | }
98 |
99 | // L2: Check disk cache
100 | if (this.diskCache) {
101 | const diskData = await this.diskCache.get<T>(fileKey, nodeId, depth, version);
102 | if (diskData !== null) {
103 | // Backfill L1 cache
104 | this.memoryCache.setNode(diskData, fileKey, nodeId, depth, version);
105 | return diskData;
106 | }
107 | }
108 |
109 | return null;
110 | }
111 |
112 | /**
113 | * Set node data in both cache layers
114 | */
115 | async setNodeData<T>(
116 | data: T,
117 | fileKey: string,
118 | nodeId?: string,
119 | depth?: number,
120 | version?: string,
121 | ): Promise<void> {
122 | if (!this.config.enabled) return;
123 |
124 | // Write to L1 (memory)
125 | this.memoryCache.setNode(data, fileKey, nodeId, depth, version);
126 |
127 | // Write to L2 (disk)
128 | if (this.diskCache) {
129 | await this.diskCache.set(data, fileKey, nodeId, depth, version);
130 | }
131 | }
132 |
133 | /**
134 | * Check if node data exists in cache
135 | */
136 | async hasNodeData(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
137 | if (!this.config.enabled) return false;
138 |
139 | const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
140 | if (this.memoryCache.has(key)) {
141 | return true;
142 | }
143 |
144 | if (this.diskCache) {
145 | return this.diskCache.has(fileKey, nodeId, depth);
146 | }
147 |
148 | return false;
149 | }
150 |
151 | // ==================== Image Operations ====================
152 |
153 | /**
154 | * Check if image is cached
155 | */
156 | async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> {
157 | if (!this.config.enabled || !this.diskCache) return null;
158 | return this.diskCache.hasImage(fileKey, nodeId, format);
159 | }
160 |
161 | /**
162 | * Cache image file
163 | */
164 | async cacheImage(
165 | sourcePath: string,
166 | fileKey: string,
167 | nodeId: string,
168 | format: string,
169 | ): Promise<string> {
170 | if (!this.config.enabled || !this.diskCache) return sourcePath;
171 | return this.diskCache.cacheImage(sourcePath, fileKey, nodeId, format);
172 | }
173 |
174 | /**
175 | * Copy image from cache to target path
176 | */
177 | async copyImageFromCache(
178 | fileKey: string,
179 | nodeId: string,
180 | format: string,
181 | targetPath: string,
182 | ): Promise<boolean> {
183 | if (!this.config.enabled || !this.diskCache) return false;
184 | return this.diskCache.copyImageFromCache(fileKey, nodeId, format, targetPath);
185 | }
186 |
187 | // ==================== Invalidation Operations ====================
188 |
189 | /**
190 | * Invalidate all cache entries for a file
191 | */
192 | async invalidateFile(fileKey: string): Promise<{ memory: number; disk: number }> {
193 | const memoryInvalidated = this.memoryCache.invalidateFile(fileKey);
194 | const diskInvalidated = this.diskCache ? await this.diskCache.invalidateFile(fileKey) : 0;
195 |
196 | return { memory: memoryInvalidated, disk: diskInvalidated };
197 | }
198 |
199 | /**
200 | * Invalidate cache for a specific node
201 | */
202 | async invalidateNode(fileKey: string, nodeId: string): Promise<{ memory: number; disk: number }> {
203 | const memoryInvalidated = this.memoryCache.invalidateNode(fileKey, nodeId);
204 | const diskInvalidated = this.diskCache
205 | ? (await this.diskCache.delete(fileKey, nodeId))
206 | ? 1
207 | : 0
208 | : 0;
209 |
210 | return { memory: memoryInvalidated, disk: diskInvalidated };
211 | }
212 |
213 | // ==================== Maintenance Operations ====================
214 |
215 | /**
216 | * Clean expired cache entries from all layers
217 | */
218 | async cleanExpired(): Promise<{ memory: number; disk: number }> {
219 | const memoryCleaned = this.memoryCache.cleanExpired();
220 | const diskCleaned = this.diskCache ? await this.diskCache.cleanExpired() : 0;
221 |
222 | return { memory: memoryCleaned, disk: diskCleaned };
223 | }
224 |
225 | /**
226 | * Clear all cache
227 | */
228 | async clearAll(): Promise<void> {
229 | this.memoryCache.clear();
230 | if (this.diskCache) {
231 | await this.diskCache.clearAll();
232 | }
233 | }
234 |
235 | /**
236 | * Get combined cache statistics
237 | */
238 | async getStats(): Promise<CacheStatistics> {
239 | const memoryStats = this.memoryCache.getStats();
240 |
241 | if (!this.diskCache) {
242 | return {
243 | enabled: this.config.enabled,
244 | memory: {
245 | hits: memoryStats.hits,
246 | misses: memoryStats.misses,
247 | size: memoryStats.size,
248 | maxSize: memoryStats.maxSize,
249 | hitRate: this.memoryCache.getHitRate(),
250 | evictions: memoryStats.evictions,
251 | },
252 | disk: {
253 | hits: 0,
254 | misses: 0,
255 | totalSize: 0,
256 | maxSize: this.config.disk.maxSize,
257 | nodeFileCount: 0,
258 | imageFileCount: 0,
259 | },
260 | };
261 | }
262 |
263 | const diskStats = await this.diskCache.getStats();
264 |
265 | return {
266 | enabled: this.config.enabled,
267 | memory: {
268 | hits: memoryStats.hits,
269 | misses: memoryStats.misses,
270 | size: memoryStats.size,
271 | maxSize: memoryStats.maxSize,
272 | hitRate: this.memoryCache.getHitRate(),
273 | evictions: memoryStats.evictions,
274 | },
275 | disk: diskStats,
276 | };
277 | }
278 |
279 | /**
280 | * Get cache directory path
281 | */
282 | getCacheDir(): string {
283 | return this.config.disk.cacheDir;
284 | }
285 |
286 | /**
287 | * Check if caching is enabled
288 | */
289 | isEnabled(): boolean {
290 | return this.config.enabled;
291 | }
292 |
293 | /**
294 | * Reset statistics
295 | */
296 | resetStats(): void {
297 | this.memoryCache.resetStats();
298 | }
299 | }
300 |
301 | // Export singleton instance
302 | export const cacheManager = new CacheManager();
303 |
```
--------------------------------------------------------------------------------
/src/core/layout.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { isFrame, isLayout, isRectangle } from "~/utils/validation.js";
2 | import type {
3 | Node as FigmaDocumentNode,
4 | HasFramePropertiesTrait,
5 | HasLayoutTrait,
6 | } from "@figma/rest-api-spec";
7 | import { generateCSSShorthand } from "~/utils/css.js";
8 |
9 | export interface SimplifiedLayout {
10 | mode: "none" | "row" | "column";
11 | justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
12 | alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
13 | alignSelf?: "flex-start" | "flex-end" | "center" | "stretch";
14 | wrap?: boolean;
15 | gap?: string;
16 | locationRelativeToParent?: {
17 | x: number;
18 | y: number;
19 | };
20 | dimensions?: {
21 | width?: number;
22 | height?: number;
23 | aspectRatio?: number;
24 | };
25 | padding?: string;
26 | sizing?: {
27 | horizontal?: "fixed" | "fill" | "hug";
28 | vertical?: "fixed" | "fill" | "hug";
29 | };
30 | overflowScroll?: ("x" | "y")[];
31 | position?: "absolute";
32 | }
33 |
34 | // Convert Figma's layout config into a more typical flex-like schema
35 | export function buildSimplifiedLayout(
36 | n: FigmaDocumentNode,
37 | parent?: FigmaDocumentNode,
38 | ): SimplifiedLayout {
39 | const frameValues = buildSimplifiedFrameValues(n);
40 | const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {};
41 |
42 | return { ...frameValues, ...layoutValues };
43 | }
44 |
45 | /**
46 | * Convert Figma's primaryAxisAlignItems to CSS justifyContent
47 | * Primary axis: horizontal for row, vertical for column
48 | */
49 | function convertJustifyContent(
50 | axisAlign?: HasFramePropertiesTrait["primaryAxisAlignItems"],
51 | stretch?: {
52 | children: FigmaDocumentNode[];
53 | mode: "row" | "column";
54 | },
55 | ) {
56 | // Check if all children fill the main axis (stretch behavior)
57 | if (stretch) {
58 | const { children, mode } = stretch;
59 | const shouldStretch =
60 | children.length > 0 &&
61 | children.every((c) => {
62 | if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
63 | // Primary axis: horizontal for row, vertical for column
64 | if (mode === "row") {
65 | return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
66 | } else {
67 | return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
68 | }
69 | });
70 |
71 | if (shouldStretch) return "stretch";
72 | }
73 |
74 | switch (axisAlign) {
75 | case "MIN":
76 | // MIN, AKA flex-start, is the default alignment
77 | return undefined;
78 | case "MAX":
79 | return "flex-end";
80 | case "CENTER":
81 | return "center";
82 | case "SPACE_BETWEEN":
83 | return "space-between";
84 | default:
85 | return undefined;
86 | }
87 | }
88 |
89 | /**
90 | * Convert Figma's counterAxisAlignItems to CSS alignItems
91 | * Counter axis: vertical for row, horizontal for column
92 | * Note: SPACE_BETWEEN is not valid for counter axis in Figma
93 | */
94 | function convertAlignItems(
95 | axisAlign?: HasFramePropertiesTrait["counterAxisAlignItems"],
96 | stretch?: {
97 | children: FigmaDocumentNode[];
98 | mode: "row" | "column";
99 | },
100 | ) {
101 | // Check if all children fill the cross axis (stretch behavior)
102 | if (stretch) {
103 | const { children, mode } = stretch;
104 | const shouldStretch =
105 | children.length > 0 &&
106 | children.every((c) => {
107 | if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
108 | // Counter axis: vertical for row, horizontal for column
109 | if (mode === "row") {
110 | return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
111 | } else {
112 | return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
113 | }
114 | });
115 |
116 | if (shouldStretch) return "stretch";
117 | }
118 |
119 | switch (axisAlign) {
120 | case "MIN":
121 | // MIN, AKA flex-start, is the default alignment
122 | return undefined;
123 | case "MAX":
124 | return "flex-end";
125 | case "CENTER":
126 | return "center";
127 | case "BASELINE":
128 | return "baseline";
129 | default:
130 | return undefined;
131 | }
132 | }
133 |
134 | function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) {
135 | switch (align) {
136 | case "MIN":
137 | // MIN, AKA flex-start, is the default alignment
138 | return undefined;
139 | case "MAX":
140 | return "flex-end";
141 | case "CENTER":
142 | return "center";
143 | case "STRETCH":
144 | return "stretch";
145 | default:
146 | return undefined;
147 | }
148 | }
149 |
150 | // interpret sizing
151 | function convertSizing(
152 | s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"],
153 | ) {
154 | if (s === "FIXED") return "fixed";
155 | if (s === "FILL") return "fill";
156 | if (s === "HUG") return "hug";
157 | return undefined;
158 | }
159 |
160 | function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } {
161 | if (!isFrame(n)) {
162 | return { mode: "none" };
163 | }
164 |
165 | const frameValues: SimplifiedLayout = {
166 | mode:
167 | !n.layoutMode || n.layoutMode === "NONE"
168 | ? "none"
169 | : n.layoutMode === "HORIZONTAL"
170 | ? "row"
171 | : "column",
172 | };
173 |
174 | const overflowScroll: SimplifiedLayout["overflowScroll"] = [];
175 | if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x");
176 | if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y");
177 | if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll;
178 |
179 | if (frameValues.mode === "none") {
180 | return frameValues;
181 | }
182 |
183 | // Convert Figma alignment to CSS flex properties
184 | frameValues.justifyContent = convertJustifyContent(n.primaryAxisAlignItems ?? "MIN", {
185 | children: n.children,
186 | mode: frameValues.mode,
187 | });
188 | frameValues.alignItems = convertAlignItems(n.counterAxisAlignItems ?? "MIN", {
189 | children: n.children,
190 | mode: frameValues.mode,
191 | });
192 | frameValues.alignSelf = convertSelfAlign(n.layoutAlign);
193 |
194 | // Only include wrap if it's set to WRAP, since flex layouts don't default to wrapping
195 | frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined;
196 | frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : undefined;
197 | // gather padding
198 | if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) {
199 | frameValues.padding = generateCSSShorthand({
200 | top: n.paddingTop ?? 0,
201 | right: n.paddingRight ?? 0,
202 | bottom: n.paddingBottom ?? 0,
203 | left: n.paddingLeft ?? 0,
204 | });
205 | }
206 |
207 | return frameValues;
208 | }
209 |
210 | function buildSimplifiedLayoutValues(
211 | n: FigmaDocumentNode,
212 | parent: FigmaDocumentNode | undefined,
213 | mode: "row" | "column" | "none",
214 | ): SimplifiedLayout | undefined {
215 | if (!isLayout(n)) return undefined;
216 |
217 | const layoutValues: SimplifiedLayout = { mode };
218 |
219 | layoutValues.sizing = {
220 | horizontal: convertSizing(n.layoutSizingHorizontal),
221 | vertical: convertSizing(n.layoutSizingVertical),
222 | };
223 |
224 | // Only include positioning-related properties if parent layout isn't flex or if the node is absolute
225 | if (isFrame(parent) && (parent?.layoutMode === "NONE" || n.layoutPositioning === "ABSOLUTE")) {
226 | if (n.layoutPositioning === "ABSOLUTE") {
227 | layoutValues.position = "absolute";
228 | }
229 | if (n.absoluteBoundingBox && parent.absoluteBoundingBox) {
230 | layoutValues.locationRelativeToParent = {
231 | x: n.absoluteBoundingBox.x - (parent?.absoluteBoundingBox?.x ?? n.absoluteBoundingBox.x),
232 | y: n.absoluteBoundingBox.y - (parent?.absoluteBoundingBox?.y ?? n.absoluteBoundingBox.y),
233 | };
234 | }
235 | return layoutValues;
236 | }
237 |
238 | // Handle dimensions based on layout growth and alignment
239 | if (isRectangle("absoluteBoundingBox", n) && isRectangle("absoluteBoundingBox", parent)) {
240 | const dimensions: { width?: number; height?: number; aspectRatio?: number } = {};
241 |
242 | // Only include dimensions that aren't meant to stretch
243 | if (mode === "row") {
244 | if (!n.layoutGrow && n.layoutSizingHorizontal === "FIXED")
245 | dimensions.width = n.absoluteBoundingBox.width;
246 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical === "FIXED")
247 | dimensions.height = n.absoluteBoundingBox.height;
248 | } else if (mode === "column") {
249 | // column
250 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal === "FIXED")
251 | dimensions.width = n.absoluteBoundingBox.width;
252 | if (!n.layoutGrow && n.layoutSizingVertical === "FIXED")
253 | dimensions.height = n.absoluteBoundingBox.height;
254 |
255 | if (n.preserveRatio) {
256 | dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height;
257 | }
258 | }
259 |
260 | if (Object.keys(dimensions).length > 0) {
261 | layoutValues.dimensions = dimensions;
262 | }
263 | }
264 |
265 | return layoutValues;
266 | }
267 |
```
--------------------------------------------------------------------------------
/tests/unit/algorithms/icon.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Icon Detection Algorithm Unit Tests
3 | *
4 | * Tests the icon detection algorithm for identifying and merging
5 | * vector layers that should be exported as single icons.
6 | */
7 |
8 | import { describe, it, expect, beforeAll } from "vitest";
9 | import * as fs from "fs";
10 | import * as path from "path";
11 | import { fileURLToPath } from "url";
12 | import {
13 | detectIcon,
14 | analyzeNodeTree,
15 | DEFAULT_CONFIG,
16 | type FigmaNode,
17 | type IconDetectionResult,
18 | } from "~/algorithms/icon/index.js";
19 |
20 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 | const fixturesPath = path.join(__dirname, "../../fixtures");
22 |
23 | // Load test fixture
24 | function loadTestData(): FigmaNode {
25 | const dataPath = path.join(fixturesPath, "figma-data/real-node-data.json");
26 | const rawData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
27 | const nodeKey = Object.keys(rawData.nodes)[0];
28 | return rawData.nodes[nodeKey].document;
29 | }
30 |
31 | // Count total icons detected
32 | function countIcons(results: IconDetectionResult[]): number {
33 | return results.filter((r) => r.shouldMerge).length;
34 | }
35 |
36 | describe("Icon Detection Algorithm", () => {
37 | let testData: FigmaNode;
38 |
39 | beforeAll(() => {
40 | testData = loadTestData();
41 | });
42 |
43 | describe("Configuration", () => {
44 | it("should have sensible default configuration", () => {
45 | expect(DEFAULT_CONFIG.maxIconSize).toBe(300);
46 | expect(DEFAULT_CONFIG.minIconSize).toBe(8);
47 | expect(DEFAULT_CONFIG.mergeableRatio).toBe(0.6);
48 | expect(DEFAULT_CONFIG.maxDepth).toBe(5);
49 | });
50 | });
51 |
52 | describe("Size Constraints", () => {
53 | it("should reject nodes that are too large", () => {
54 | const largeNode: FigmaNode = {
55 | id: "large-1",
56 | name: "Large Node",
57 | type: "GROUP",
58 | absoluteBoundingBox: { x: 0, y: 0, width: 500, height: 500 },
59 | children: [{ id: "child-1", name: "Vector", type: "VECTOR" }],
60 | };
61 |
62 | const result = detectIcon(largeNode, DEFAULT_CONFIG);
63 | expect(result.shouldMerge).toBe(false);
64 | expect(result.reason).toContain("large");
65 | });
66 |
67 | it("should reject nodes that are too small", () => {
68 | const smallNode: FigmaNode = {
69 | id: "small-1",
70 | name: "Small Node",
71 | type: "GROUP",
72 | absoluteBoundingBox: { x: 0, y: 0, width: 4, height: 4 },
73 | children: [{ id: "child-1", name: "Vector", type: "VECTOR" }],
74 | };
75 |
76 | const result = detectIcon(smallNode, DEFAULT_CONFIG);
77 | expect(result.shouldMerge).toBe(false);
78 | });
79 | });
80 |
81 | describe("Node Type Detection", () => {
82 | it("should detect vector-only groups as icons", () => {
83 | const vectorGroup: FigmaNode = {
84 | id: "icon-1",
85 | name: "Search Icon",
86 | type: "GROUP",
87 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
88 | children: [
89 | { id: "v1", name: "Circle", type: "ELLIPSE" },
90 | { id: "v2", name: "Line", type: "LINE" },
91 | ],
92 | };
93 |
94 | const result = detectIcon(vectorGroup, DEFAULT_CONFIG);
95 | expect(result.shouldMerge).toBe(true);
96 | expect(result.exportFormat).toBe("SVG");
97 | });
98 |
99 | it("should reject groups containing TEXT nodes", () => {
100 | const textGroup: FigmaNode = {
101 | id: "text-group",
102 | name: "Button with Text",
103 | type: "GROUP",
104 | absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 40 },
105 | children: [
106 | { id: "bg", name: "Background", type: "RECTANGLE" },
107 | { id: "label", name: "Label", type: "TEXT" },
108 | ],
109 | };
110 |
111 | const result = detectIcon(textGroup, DEFAULT_CONFIG);
112 | expect(result.shouldMerge).toBe(false);
113 | expect(result.reason).toContain("TEXT");
114 | });
115 | });
116 |
117 | describe("Export Format Selection", () => {
118 | it("should choose SVG for pure vector icons", () => {
119 | const vectorIcon: FigmaNode = {
120 | id: "svg-icon",
121 | name: "Star",
122 | type: "GROUP",
123 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
124 | children: [{ id: "star", name: "Star", type: "STAR" }],
125 | };
126 |
127 | const result = detectIcon(vectorIcon, DEFAULT_CONFIG);
128 | expect(result.shouldMerge).toBe(true);
129 | expect(result.exportFormat).toBe("SVG");
130 | });
131 |
132 | it("should choose PNG for icons with complex effects", () => {
133 | const effectIcon: FigmaNode = {
134 | id: "effect-icon",
135 | name: "Shadow Icon",
136 | type: "GROUP",
137 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
138 | effects: [{ type: "DROP_SHADOW", visible: true }],
139 | children: [{ id: "shape", name: "Shape", type: "RECTANGLE" }],
140 | };
141 |
142 | const result = detectIcon(effectIcon, DEFAULT_CONFIG);
143 | if (result.shouldMerge) {
144 | expect(result.exportFormat).toBe("PNG");
145 | }
146 | });
147 |
148 | it("should respect designer-specified export settings", () => {
149 | const exportNode: FigmaNode = {
150 | id: "export-icon",
151 | name: "Custom Export",
152 | type: "GROUP",
153 | absoluteBoundingBox: { x: 0, y: 0, width: 32, height: 32 },
154 | exportSettings: [{ format: "PNG", suffix: "", constraint: { type: "SCALE", value: 2 } }],
155 | children: [{ id: "v1", name: "Vector", type: "VECTOR" }],
156 | };
157 |
158 | const result = detectIcon(exportNode, DEFAULT_CONFIG);
159 | expect(result.shouldMerge).toBe(true);
160 | expect(result.exportFormat).toBe("PNG");
161 | });
162 | });
163 |
164 | describe("Mergeable Types", () => {
165 | const mergeableTypes = [
166 | "VECTOR",
167 | "ELLIPSE",
168 | "RECTANGLE",
169 | "STAR",
170 | "POLYGON",
171 | "LINE",
172 | "BOOLEAN_OPERATION",
173 | ];
174 |
175 | mergeableTypes.forEach((type) => {
176 | it(`should recognize ${type} as mergeable`, () => {
177 | const node: FigmaNode = {
178 | id: `${type.toLowerCase()}-icon`,
179 | name: `${type} Icon`,
180 | type: "GROUP",
181 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
182 | children: [{ id: "child", name: type, type: type }],
183 | };
184 |
185 | const result = detectIcon(node, DEFAULT_CONFIG);
186 | expect(result.shouldMerge).toBe(true);
187 | });
188 | });
189 | });
190 |
191 | describe("Real Figma Data", () => {
192 | it("should load and parse real Figma data", () => {
193 | expect(testData).toBeDefined();
194 | expect(testData.type).toBe("GROUP");
195 | });
196 |
197 | it("should analyze entire node tree", () => {
198 | const result = analyzeNodeTree(testData, DEFAULT_CONFIG);
199 | expect(result).toHaveProperty("processedTree");
200 | expect(result).toHaveProperty("exportableIcons");
201 | expect(result).toHaveProperty("summary");
202 | expect(Array.isArray(result.exportableIcons)).toBe(true);
203 | });
204 |
205 | it("should detect appropriate number of icons", () => {
206 | const result = analyzeNodeTree(testData, DEFAULT_CONFIG);
207 | const iconCount = countIcons(result.exportableIcons);
208 |
209 | // Should detect some icons but not too many (avoid fragmentation)
210 | expect(iconCount).toBeGreaterThanOrEqual(0);
211 | expect(iconCount).toBeLessThan(10); // Should be merged, not fragmented
212 | });
213 |
214 | it("should not mark root node as icon", () => {
215 | const result = detectIcon(testData, DEFAULT_CONFIG);
216 | expect(result.shouldMerge).toBe(false);
217 | });
218 | });
219 |
220 | describe("Edge Cases", () => {
221 | it("should handle nodes without children", () => {
222 | const leafNode: FigmaNode = {
223 | id: "leaf",
224 | name: "Single Vector",
225 | type: "VECTOR",
226 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
227 | };
228 |
229 | const result = detectIcon(leafNode, DEFAULT_CONFIG);
230 | expect(result).toBeDefined();
231 | });
232 |
233 | it("should handle nodes without bounding box", () => {
234 | const noBoundsNode: FigmaNode = {
235 | id: "no-bounds",
236 | name: "No Bounds",
237 | type: "GROUP",
238 | children: [],
239 | };
240 |
241 | const result = detectIcon(noBoundsNode, DEFAULT_CONFIG);
242 | expect(result.shouldMerge).toBe(false);
243 | });
244 |
245 | it("should handle deeply nested structures", () => {
246 | const deepNode: FigmaNode = {
247 | id: "deep",
248 | name: "Deep",
249 | type: "GROUP",
250 | absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
251 | children: [
252 | {
253 | id: "level1",
254 | name: "Level 1",
255 | type: "GROUP",
256 | children: [
257 | {
258 | id: "level2",
259 | name: "Level 2",
260 | type: "GROUP",
261 | children: [{ id: "vector", name: "Vector", type: "VECTOR" }],
262 | },
263 | ],
264 | },
265 | ],
266 | };
267 |
268 | const result = detectIcon(deepNode, DEFAULT_CONFIG);
269 | expect(result).toBeDefined();
270 | });
271 | });
272 | });
273 |
```
--------------------------------------------------------------------------------
/tests/integration/parser.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Parser Integration Tests
3 | *
4 | * Tests the complete parsing pipeline from raw Figma API response
5 | * to simplified node structure.
6 | */
7 |
8 | import { describe, it, expect, beforeAll } from "vitest";
9 | import * as fs from "fs";
10 | import * as path from "path";
11 | import { fileURLToPath } from "url";
12 | import { parseFigmaResponse } from "~/core/parser.js";
13 |
14 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
15 | const fixturesPath = path.join(__dirname, "../fixtures");
16 |
17 | // Load fixtures
18 | function loadRawData(): unknown {
19 | const dataPath = path.join(fixturesPath, "figma-data/real-node-data.json");
20 | return JSON.parse(fs.readFileSync(dataPath, "utf-8"));
21 | }
22 |
23 | function loadExpectedOutput(): unknown {
24 | const dataPath = path.join(fixturesPath, "expected/real-node-data-optimized.json");
25 | return JSON.parse(fs.readFileSync(dataPath, "utf-8"));
26 | }
27 |
28 | describe("Figma Response Parser", () => {
29 | let rawData: unknown;
30 | let expectedOutput: ReturnType<typeof loadExpectedOutput>;
31 |
32 | beforeAll(() => {
33 | rawData = loadRawData();
34 | expectedOutput = loadExpectedOutput();
35 | });
36 |
37 | describe("Basic Parsing", () => {
38 | it("should parse raw Figma response", () => {
39 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
40 |
41 | expect(result).toBeDefined();
42 | expect(result.name).toBeDefined();
43 | expect(result.nodes).toBeDefined();
44 | expect(Array.isArray(result.nodes)).toBe(true);
45 | });
46 |
47 | it("should extract file metadata", () => {
48 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
49 |
50 | expect(result.name).toBe("Vigilkids产品站");
51 | expect(result.lastModified).toBeDefined();
52 | });
53 | });
54 |
55 | describe("Node Structure", () => {
56 | it("should preserve node hierarchy", () => {
57 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
58 |
59 | expect(result.nodes.length).toBeGreaterThan(0);
60 |
61 | const rootNode = result.nodes[0];
62 | expect(rootNode.id).toBeDefined();
63 | expect(rootNode.name).toBeDefined();
64 | expect(rootNode.type).toBeDefined();
65 | });
66 |
67 | it("should generate CSS styles", () => {
68 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
69 |
70 | const rootNode = result.nodes[0];
71 | expect(rootNode.cssStyles).toBeDefined();
72 | expect(rootNode.cssStyles?.width).toBeDefined();
73 | expect(rootNode.cssStyles?.height).toBeDefined();
74 | });
75 | });
76 |
77 | describe("Data Compression", () => {
78 | it("should significantly reduce data size", () => {
79 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
80 |
81 | const originalSize = Buffer.byteLength(JSON.stringify(rawData));
82 | const simplifiedSize = Buffer.byteLength(JSON.stringify(result));
83 | const compressionRate = ((originalSize - simplifiedSize) / originalSize) * 100;
84 |
85 | // Should achieve at least 70% compression
86 | expect(compressionRate).toBeGreaterThan(70);
87 | });
88 | });
89 |
90 | describe("CSS Style Generation", () => {
91 | it("should convert colors to hex format", () => {
92 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
93 |
94 | // Find a node with background color
95 | const findNodeWithBg = (
96 | nodes: Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
97 | ): Record<string, unknown> | null => {
98 | for (const node of nodes) {
99 | if (node.cssStyles?.backgroundColor) {
100 | return node.cssStyles;
101 | }
102 | if (node.children) {
103 | const found = findNodeWithBg(
104 | node.children as Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
105 | );
106 | if (found) return found;
107 | }
108 | }
109 | return null;
110 | };
111 |
112 | const styles = findNodeWithBg(result.nodes);
113 | if (styles?.backgroundColor) {
114 | expect(styles.backgroundColor).toMatch(/^#[A-Fa-f0-9]{6}$/);
115 | }
116 | });
117 |
118 | it("should round pixel values to integers", () => {
119 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
120 |
121 | const rootNode = result.nodes[0];
122 | const width = rootNode.cssStyles?.width as string;
123 | const height = rootNode.cssStyles?.height as string;
124 |
125 | // Should be integer pixel values
126 | expect(width).toMatch(/^\d+px$/);
127 | expect(height).toMatch(/^\d+px$/);
128 | });
129 | });
130 |
131 | describe("Layout Detection Integration", () => {
132 | it("should detect flex layouts in appropriate nodes", () => {
133 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
134 |
135 | // Find nodes with flex properties
136 | const findFlexNode = (
137 | nodes: Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
138 | ): Record<string, unknown> | null => {
139 | for (const node of nodes) {
140 | if (node.cssStyles?.display === "flex") {
141 | return node.cssStyles;
142 | }
143 | if (node.children) {
144 | const found = findFlexNode(
145 | node.children as Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
146 | );
147 | if (found) return found;
148 | }
149 | }
150 | return null;
151 | };
152 |
153 | const flexStyles = findFlexNode(result.nodes);
154 | if (flexStyles) {
155 | expect(flexStyles.display).toBe("flex");
156 | expect(flexStyles.flexDirection).toBeDefined();
157 | }
158 | });
159 | });
160 |
161 | describe("Icon Detection Integration", () => {
162 | it("should mark icon nodes with exportInfo", () => {
163 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
164 |
165 | // Find nodes with export info
166 | const findExportNode = (
167 | nodes: Array<{ exportInfo?: unknown; children?: unknown[] }>,
168 | ): unknown | null => {
169 | for (const node of nodes) {
170 | if (node.exportInfo) {
171 | return node.exportInfo;
172 | }
173 | if (node.children) {
174 | const found = findExportNode(
175 | node.children as Array<{ exportInfo?: unknown; children?: unknown[] }>,
176 | );
177 | if (found) return found;
178 | }
179 | }
180 | return null;
181 | };
182 |
183 | const exportInfo = findExportNode(result.nodes);
184 | if (exportInfo) {
185 | expect(exportInfo).toHaveProperty("type");
186 | expect(exportInfo).toHaveProperty("format");
187 | expect(exportInfo).toHaveProperty("fileName");
188 | }
189 | });
190 | });
191 |
192 | describe("Text Node Processing", () => {
193 | it("should extract text content", () => {
194 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
195 |
196 | // Find text nodes
197 | const findTextNode = (
198 | nodes: Array<{ type?: string; text?: string; children?: unknown[] }>,
199 | ): { text?: string } | null => {
200 | for (const node of nodes) {
201 | if (node.type === "TEXT" && node.text) {
202 | return node;
203 | }
204 | if (node.children) {
205 | const found = findTextNode(
206 | node.children as Array<{ type?: string; text?: string; children?: unknown[] }>,
207 | );
208 | if (found) return found;
209 | }
210 | }
211 | return null;
212 | };
213 |
214 | const textNode = findTextNode(result.nodes);
215 | if (textNode) {
216 | expect(textNode.text).toBeDefined();
217 | expect(typeof textNode.text).toBe("string");
218 | }
219 | });
220 |
221 | it("should include font styles for text nodes", () => {
222 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
223 |
224 | const findTextStyles = (
225 | nodes: Array<{ type?: string; cssStyles?: Record<string, unknown>; children?: unknown[] }>,
226 | ): Record<string, unknown> | null => {
227 | for (const node of nodes) {
228 | if (node.type === "TEXT" && node.cssStyles) {
229 | return node.cssStyles;
230 | }
231 | if (node.children) {
232 | const found = findTextStyles(
233 | node.children as Array<{
234 | type?: string;
235 | cssStyles?: Record<string, unknown>;
236 | children?: unknown[];
237 | }>,
238 | );
239 | if (found) return found;
240 | }
241 | }
242 | return null;
243 | };
244 |
245 | const textStyles = findTextStyles(result.nodes);
246 | if (textStyles) {
247 | expect(textStyles.fontFamily).toBeDefined();
248 | expect(textStyles.fontSize).toBeDefined();
249 | }
250 | });
251 | });
252 |
253 | describe("Output Stability", () => {
254 | it("should produce consistent output structure", () => {
255 | const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
256 |
257 | // Compare key structure with expected output
258 | expect(Object.keys(result)).toEqual(Object.keys(expectedOutput as object));
259 | expect(result.nodes.length).toBe((expectedOutput as { nodes: unknown[] }).nodes.length);
260 | });
261 | });
262 | });
263 |
```
--------------------------------------------------------------------------------
/src/resources/figma-resources.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Figma Resources - Expose Figma data as MCP Resources
3 | * Resources are lightweight, on-demand data sources that save tokens
4 | */
5 |
6 | import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7 | import type { FigmaService } from "../services/figma.js";
8 | import type { SimplifiedNode } from "../types/index.js";
9 |
10 | // ==================== Types ====================
11 |
12 | export interface FileMetadata {
13 | name: string;
14 | lastModified: string;
15 | version: string;
16 | pages: Array<{ id: string; name: string; childCount: number }>;
17 | thumbnailUrl?: string;
18 | }
19 |
20 | export interface StyleTokens {
21 | colors: Array<{ name: string; value: string; hex: string }>;
22 | typography: Array<{
23 | name: string;
24 | fontFamily: string;
25 | fontSize: number;
26 | fontWeight: number;
27 | lineHeight?: number;
28 | }>;
29 | effects: Array<{ name: string; type: string; value: string }>;
30 | }
31 |
32 | export interface ComponentSummary {
33 | id: string;
34 | name: string;
35 | description?: string;
36 | type: "COMPONENT" | "COMPONENT_SET";
37 | variants?: string[];
38 | }
39 |
40 | // ==================== Resource Handlers ====================
41 |
42 | /**
43 | * Extract file metadata (lightweight, ~200 tokens)
44 | */
45 | export async function getFileMetadata(
46 | figmaService: FigmaService,
47 | fileKey: string,
48 | ): Promise<FileMetadata> {
49 | const file = await figmaService.getFile(fileKey, 1); // depth=1 for minimal data
50 |
51 | const pages = file.nodes
52 | .filter((node) => node.type === "CANVAS")
53 | .map((page) => ({
54 | id: page.id,
55 | name: page.name,
56 | childCount: page.children?.length ?? 0,
57 | }));
58 |
59 | return {
60 | name: file.name,
61 | lastModified: file.lastModified,
62 | version: file.version ?? "",
63 | pages,
64 | };
65 | }
66 |
67 | /**
68 | * Extract style tokens from file (colors, typography, effects) (~500 tokens)
69 | */
70 | export async function getStyleTokens(
71 | figmaService: FigmaService,
72 | fileKey: string,
73 | ): Promise<StyleTokens> {
74 | const file = await figmaService.getFile(fileKey, 3); // Need some depth for styles
75 |
76 | const colors: StyleTokens["colors"] = [];
77 | const typography: StyleTokens["typography"] = [];
78 | const effects: StyleTokens["effects"] = [];
79 | const seenColors = new Set<string>();
80 | const seenFonts = new Set<string>();
81 |
82 | function extractFromNode(node: SimplifiedNode) {
83 | // Extract colors from fills
84 | if (node.cssStyles) {
85 | const bgColor = node.cssStyles.background || node.cssStyles.backgroundColor;
86 | if (bgColor && !seenColors.has(bgColor)) {
87 | seenColors.add(bgColor);
88 | colors.push({
89 | name: node.name || "unnamed",
90 | value: bgColor,
91 | hex: bgColor,
92 | });
93 | }
94 |
95 | const textColor = node.cssStyles.color;
96 | if (textColor && !seenColors.has(textColor)) {
97 | seenColors.add(textColor);
98 | colors.push({
99 | name: `${node.name || "text"}-color`,
100 | value: textColor,
101 | hex: textColor,
102 | });
103 | }
104 |
105 | // Extract typography
106 | if (node.cssStyles.fontFamily && node.cssStyles.fontSize) {
107 | const fontKey = `${node.cssStyles.fontFamily}-${node.cssStyles.fontSize}-${node.cssStyles.fontWeight || 400}`;
108 | if (!seenFonts.has(fontKey)) {
109 | seenFonts.add(fontKey);
110 | typography.push({
111 | name: node.name || "text",
112 | fontFamily: node.cssStyles.fontFamily,
113 | fontSize: parseFloat(String(node.cssStyles.fontSize)) || 14,
114 | fontWeight: parseFloat(String(node.cssStyles.fontWeight)) || 400,
115 | lineHeight: node.cssStyles.lineHeight
116 | ? parseFloat(String(node.cssStyles.lineHeight))
117 | : undefined,
118 | });
119 | }
120 | }
121 |
122 | // Extract effects (shadows, blur)
123 | if (node.cssStyles.boxShadow) {
124 | effects.push({
125 | name: `${node.name || "element"}-shadow`,
126 | type: "shadow",
127 | value: String(node.cssStyles.boxShadow),
128 | });
129 | }
130 | }
131 |
132 | // Recurse into children
133 | if (node.children) {
134 | for (const child of node.children) {
135 | extractFromNode(child);
136 | }
137 | }
138 | }
139 |
140 | for (const node of file.nodes) {
141 | extractFromNode(node);
142 | }
143 |
144 | // Limit results to avoid token bloat
145 | return {
146 | colors: colors.slice(0, 20),
147 | typography: typography.slice(0, 10),
148 | effects: effects.slice(0, 10),
149 | };
150 | }
151 |
152 | /**
153 | * Extract component list (~300 tokens)
154 | */
155 | export async function getComponentList(
156 | figmaService: FigmaService,
157 | fileKey: string,
158 | ): Promise<ComponentSummary[]> {
159 | const file = await figmaService.getFile(fileKey, 5); // Need depth for components
160 | const components: ComponentSummary[] = [];
161 |
162 | function findComponents(node: SimplifiedNode) {
163 | if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
164 | components.push({
165 | id: node.id,
166 | name: node.name,
167 | type: node.type as "COMPONENT" | "COMPONENT_SET",
168 | variants:
169 | node.type === "COMPONENT_SET" ? node.children?.map((c) => c.name).slice(0, 5) : undefined,
170 | });
171 | }
172 |
173 | if (node.children) {
174 | for (const child of node.children) {
175 | findComponents(child);
176 | }
177 | }
178 | }
179 |
180 | for (const node of file.nodes) {
181 | findComponents(node);
182 | }
183 |
184 | return components.slice(0, 50); // Limit to 50 components
185 | }
186 |
187 | /**
188 | * Extract images/assets list (~400 tokens)
189 | */
190 | export async function getAssetList(
191 | figmaService: FigmaService,
192 | fileKey: string,
193 | ): Promise<
194 | Array<{
195 | nodeId: string;
196 | name: string;
197 | type: "vector" | "image" | "icon";
198 | exportFormats: string[];
199 | imageRef?: string;
200 | }>
201 | > {
202 | const file = await figmaService.getFile(fileKey, 5);
203 | const assets: Array<{
204 | nodeId: string;
205 | name: string;
206 | type: "vector" | "image" | "icon";
207 | exportFormats: string[];
208 | imageRef?: string;
209 | }> = [];
210 |
211 | function findAssets(node: SimplifiedNode) {
212 | // Check for exportable assets (exportInfo is a single object, not array)
213 | if (node.exportInfo) {
214 | const isIcon =
215 | node.type === "VECTOR" ||
216 | node.type === "BOOLEAN_OPERATION" ||
217 | node.exportInfo.type === "IMAGE";
218 |
219 | assets.push({
220 | nodeId: node.id,
221 | name: node.name,
222 | type: isIcon ? "icon" : "vector",
223 | exportFormats: [node.exportInfo.format],
224 | });
225 | }
226 |
227 | // Check for image fills in fills array
228 | const imageFill = node.fills?.find(
229 | (fill): fill is { type: "IMAGE"; imageRef?: string } =>
230 | typeof fill === "object" && "type" in fill && fill.type === "IMAGE",
231 | );
232 | if (imageFill?.imageRef) {
233 | assets.push({
234 | nodeId: node.id,
235 | name: node.name,
236 | type: "image",
237 | exportFormats: ["png", "jpg"],
238 | imageRef: imageFill.imageRef,
239 | });
240 | }
241 |
242 | if (node.children) {
243 | for (const child of node.children) {
244 | findAssets(child);
245 | }
246 | }
247 | }
248 |
249 | for (const node of file.nodes) {
250 | findAssets(node);
251 | }
252 |
253 | return assets.slice(0, 100); // Limit to 100 assets
254 | }
255 |
256 | // ==================== Resource Templates ====================
257 |
258 | /**
259 | * Create resource template for file metadata
260 | */
261 | export function createFileMetadataTemplate(): ResourceTemplate {
262 | return new ResourceTemplate("figma://file/{fileKey}", {
263 | list: undefined, // Can't list all files without user's file list
264 | complete: {
265 | fileKey: async () => [], // Could be enhanced with recent files
266 | },
267 | });
268 | }
269 |
270 | /**
271 | * Create resource template for style tokens
272 | */
273 | export function createStylesTemplate(): ResourceTemplate {
274 | return new ResourceTemplate("figma://file/{fileKey}/styles", {
275 | list: undefined,
276 | complete: {
277 | fileKey: async () => [],
278 | },
279 | });
280 | }
281 |
282 | /**
283 | * Create resource template for components
284 | */
285 | export function createComponentsTemplate(): ResourceTemplate {
286 | return new ResourceTemplate("figma://file/{fileKey}/components", {
287 | list: undefined,
288 | complete: {
289 | fileKey: async () => [],
290 | },
291 | });
292 | }
293 |
294 | /**
295 | * Create resource template for assets
296 | */
297 | export function createAssetsTemplate(): ResourceTemplate {
298 | return new ResourceTemplate("figma://file/{fileKey}/assets", {
299 | list: undefined,
300 | complete: {
301 | fileKey: async () => [],
302 | },
303 | });
304 | }
305 |
306 | // ==================== Help Content ====================
307 |
308 | export const FIGMA_MCP_HELP = `# Figma MCP Server - Resource Guide
309 |
310 | ## Available Resources
311 |
312 | ### File Metadata
313 | \`figma://file/{fileKey}\`
314 | Returns: File name, pages, last modified date
315 | Token cost: ~200
316 |
317 | ### Design Tokens (Styles)
318 | \`figma://file/{fileKey}/styles\`
319 | Returns: Colors, typography, effects extracted from file
320 | Token cost: ~500
321 |
322 | ### Component List
323 | \`figma://file/{fileKey}/components\`
324 | Returns: All components and component sets with variants
325 | Token cost: ~300
326 |
327 | ### Asset List
328 | \`figma://file/{fileKey}/assets\`
329 | Returns: Exportable images, icons, vectors with node IDs
330 | Token cost: ~400
331 |
332 | ## How to Get fileKey
333 |
334 | From Figma URL: \`figma.com/file/{fileKey}/...\`
335 | Or: \`figma.com/design/{fileKey}/...\`
336 |
337 | ## Example Usage
338 |
339 | 1. Read file metadata: \`figma://file/abc123\`
340 | 2. Get color palette: \`figma://file/abc123/styles\`
341 | 3. List components: \`figma://file/abc123/components\`
342 | 4. Find assets to download: \`figma://file/abc123/assets\`
343 |
344 | ## Tools vs Resources
345 |
346 | - **Resources**: Read-only data, user-controlled, lightweight
347 | - **Tools**: Actions (download images), AI-controlled, heavier
348 |
349 | Use Resources for exploration, Tools for execution.
350 | `;
351 |
```