This is page 1 of 6. Use http://codebase.md/1yhy/figma-context-mcp?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:
--------------------------------------------------------------------------------
```
v20
```
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
```
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
```json
{
"src/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"tests/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml,yaml}": ["prettier --write"]
}
```
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
```
# EditorConfig - https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{json,yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Dependencies
node_modules
.pnpm-store
# Build output
dist
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage
tests/utils/simplified-with-css.json
# OS
.DS_Store
Thumbs.db
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
<h1 align="center">
<br>
<img src="https://upload.wikimedia.org/wikipedia/commons/3/33/Figma-logo.svg" alt="Figma MCP" width="80">
<br>
Figma Context MCP
<br>
</h1>
<p align="center">
<strong>MCP server for seamless Figma design integration with AI coding tools</strong>
</p>
<p align="center">
<a href="https://smithery.ai/server/@1yhy/Figma-Context-MCP">
<img src="https://smithery.ai/badge/@1yhy/Figma-Context-MCP" alt="Smithery Badge">
</a>
<a href="https://www.npmjs.com/package/@yhy2001/figma-mcp-server">
<img src="https://img.shields.io/npm/v/@yhy2001/figma-mcp-server" alt="npm version">
</a>
<a href="https://github.com/1yhy/Figma-Context-MCP/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/1yhy/Figma-Context-MCP" alt="License">
</a>
<a href="https://github.com/1yhy/Figma-Context-MCP/stargazers">
<img src="https://img.shields.io/github/stars/1yhy/Figma-Context-MCP" alt="Stars">
</a>
<img src="https://img.shields.io/badge/TypeScript-5.7-blue?logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/MCP-1.24-green" alt="MCP SDK">
</p>
<p align="center">
<a href="#features">Features</a> •
<a href="#quick-start">Quick Start</a> •
<a href="#mcp-capabilities">MCP Capabilities</a> •
<a href="#architecture">Architecture</a> •
<a href="#documentation">Documentation</a> •
<a href="./README.zh-CN.md">中文文档</a>
</p>
---
## What is This?
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/).
When AI tools can access Figma design data directly, they generate more accurate code on the first try—far better than using screenshots.
> **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.
## Features
### Core Capabilities
| Capability | Description |
| ------------------------------- | ------------------------------------------------------------------- |
| **Smart Layout Detection** | Automatically infers Flexbox/Grid layouts from absolute positioning |
| **Icon Merging** | Intelligently merges vector layers into single exportable icons |
| **CSS Generation** | Converts Figma styles to clean, usable CSS |
| **Image Export** | Downloads images and icons with proper naming |
| **Multi-layer Caching** | L1 memory + L2 disk cache to reduce API calls |
| **Design-to-Code Prompts** | Built-in professional prompt templates to guide AI code generation |
| **Lightweight Resource Access** | Resources API provides low-token data access |
### Key Improvements
| Feature | Before | After |
| ---------------- | --------------- | ------------------------------- |
| Icon exports | ~45 fragmented | 2 merged (96% reduction) |
| Layout detection | Manual absolute | Auto Flexbox/Grid inference |
| CSS output | Raw values | Optimized with defaults removed |
| API calls | Every request | 24-hour smart caching |
## Quick Start
### Prerequisites
- Node.js >= 18.0.0
- A Figma account with API access
### Installation
**Via Smithery (Recommended)**
```bash
npx -y @smithery/cli install @1yhy/Figma-Context-MCP --client claude
```
**Via npm**
```bash
npm install -g @yhy2001/figma-mcp-server
```
**From Source**
```bash
git clone https://github.com/1yhy/Figma-Context-MCP.git
cd Figma-Context-MCP
pnpm install
pnpm build
```
### Configuration
#### 1. Get Figma API Token
1. Go to [Figma Account Settings](https://www.figma.com/settings)
2. Scroll to "Personal access tokens"
3. Click "Create new token"
4. Copy the token
#### 2. Configure Your AI Tool
<details>
<summary><strong>Cursor / Windsurf / Cline</strong></summary>
Add to your MCP configuration file:
```json
{
"mcpServers": {
"Figma": {
"command": "npx",
"args": ["-y", "@yhy2001/figma-mcp-server", "--stdio"],
"env": {
"FIGMA_API_KEY": "your-figma-api-key"
}
}
}
}
```
</details>
<details>
<summary><strong>HTTP/SSE Mode (Local Development)</strong></summary>
```bash
# From source (development)
cp .env.example .env # Add FIGMA_API_KEY to .env
pnpm install && pnpm build
pnpm start # Starts on port 3333
# Or with environment variable
FIGMA_API_KEY=<your-key> pnpm start
# Or via global install
figma-mcp --figma-api-key=<your-key> --port=3333
# Connect via SSE
# URL: http://localhost:3333/sse
```
</details>
### Usage Example
```
Please implement this Figma design: https://www.figma.com/design/abc123/MyDesign?node-id=1:234
Use React and Tailwind CSS.
```
---
## MCP Capabilities
This server provides full MCP capabilities support:
```
┌─────────────────────────────────────────────────────────────┐
│ Figma MCP Server v1.1.0 │
├─────────────────────────────────────────────────────────────┤
│ Tools (2) AI-invoked operations │
│ ├── get_figma_data Fetch design data │
│ └── download_figma_images Download image assets │
├─────────────────────────────────────────────────────────────┤
│ Prompts (3) User-selected templates │
│ ├── design_to_code Full design-to-code flow │
│ ├── analyze_components Component structure │
│ └── extract_styles Style token extraction │
├─────────────────────────────────────────────────────────────┤
│ Resources (5) Lightweight data sources │
│ ├── figma://help Usage guide │
│ ├── figma://file/{key} File metadata (~200 tok) │
│ ├── figma://file/{key}/styles Design tokens (~500 tok) │
│ ├── figma://file/{key}/components Component list (~300 tok)│
│ └── figma://file/{key}/assets Asset inventory (~400 tok) │
└─────────────────────────────────────────────────────────────┘
```
### Tools
| Tool | Description | Parameters |
| ----------------------- | ---------------------------- | --------------------------------- |
| `get_figma_data` | Fetch simplified design data | `fileKey`, `nodeId?`, `depth?` |
| `download_figma_images` | Download images and icons | `fileKey`, `nodes[]`, `localPath` |
### Prompts
Built-in professional prompt templates to help AI generate high-quality code:
| Prompt | Description | Parameters |
| -------------------- | ------------------------------------------- | ---------------------------------- |
| `design_to_code` | Complete design-to-code workflow | `framework?`, `includeResponsive?` |
| `analyze_components` | Analyze component structure and reusability | - |
| `extract_styles` | Extract design tokens | - |
**design_to_code workflow includes:**
1. **Project Analysis** - Read theme config, global styles, component library
2. **Structure Analysis** - Identify page patterns, component splitting strategy
3. **ASCII Layout Blueprint** - Generate layout diagram with component and asset annotations
4. **Asset Management** - Analyze, download, and organize images/icons
5. **Code Generation** - Generate code following project conventions
6. **Accessibility Optimization** - Semantic HTML, ARIA labels
7. **Responsive Adaptation** - Mobile layout adjustments
### Resources
Lightweight data access to save tokens:
```bash
# Get file metadata (~200 tokens)
figma://file/abc123
# Get design tokens (~500 tokens)
figma://file/abc123/styles
# Get component list (~300 tokens)
figma://file/abc123/components
# Get asset inventory (~400 tokens)
figma://file/abc123/assets
```
**Resources vs Tools comparison:**
| Feature | Tools | Resources |
| ---------- | ------------------ | --------------------- |
| Controller | AI auto-invoked | User/client initiated |
| Token cost | Higher (full data) | Lower (summaries) |
| Use case | Execute actions | Browse and explore |
---
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ MCP Server │
├──────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Tools │ │ Prompts │ │ Resources │ │
│ │ (2 tools) │ │ (3 prompts) │ │ (5 resources) │ │
│ └──────┬──────┘ └─────────────┘ └──────────┬──────────┘ │
│ │ │ │
│ └──────────────────┬───────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ FigmaService │ │
│ │ API Calls • Validation • Error Handling │ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────┴─────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ CacheManager │ │ Parser + Algo │ │
│ │ L1: LRU Memory │ │ • Layout Detection │ │
│ │ L2: Disk Store │ │ • Icon Merging │ │
│ └─────────────────┘ │ • CSS Generation │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### Cache System
Two-layer cache architecture significantly reduces API calls:
| Layer | Storage | Capacity | TTL | Purpose |
| ----- | ---------- | --------------------- | -------- | -------------------- |
| L1 | Memory LRU | 100 nodes / 50 images | 5-10 min | Hot data fast access |
| L2 | Disk | 500MB | 24 hours | Persistent cache |
### Layout Detection Algorithm
Automatically converts absolute positioning to semantic Flexbox/Grid layouts:
```
Input (Figma absolute positioning):
┌─────────────────────────┐
│ ■ (10,10) ■ (110,10) │
│ ■ (10,60) ■ (110,60) │
└─────────────────────────┘
Output (Inferred Grid):
display: grid
grid-template-columns: 100px 100px
grid-template-rows: 50px 50px
gap: 10px
```
---
## Project Structure
```
src/
├── algorithms/ # Smart algorithms
│ ├── layout/ # Layout detection (Flex/Grid inference)
│ └── icon/ # Icon merge detection
├── core/ # Core parsing
│ ├── parser.ts # Figma data parser
│ ├── style.ts # CSS style generation
│ ├── layout.ts # Layout processing
│ └── effects.ts # Effects handling
├── services/ # Service layer
│ ├── figma.ts # Figma API client
│ └── cache/ # Multi-layer cache system
├── prompts/ # MCP prompt templates
├── resources/ # MCP resource handlers
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── server.ts # MCP server main entry
└── index.ts # CLI entry
tests/
├── fixtures/ # Test data
│ ├── figma-data/ # Raw JSON from Figma API
│ └── expected/ # Expected output snapshots
├── integration/ # Integration tests
│ ├── layout-optimization.test.ts # Layout optimization tests
│ ├── output-quality.test.ts # Output quality validation
│ └── parser.test.ts # Parser tests
└── unit/ # Unit tests
├── algorithms/ # Algorithm tests (layout, icon detection)
├── resources/ # Resource handler tests
└── services/ # Service layer tests
scripts/
└── fetch-test-data.ts # Figma test data fetcher
```
---
## Documentation
### Core Algorithms
| English | 中文 |
| ----------------------------------------------------- | -------------------------------------------------- |
| [Layout Detection](./docs/en/layout-detection.md) | [布局检测算法](./docs/zh-CN/layout-detection.md) |
| [Icon Detection](./docs/en/icon-detection.md) | [图标检测算法](./docs/zh-CN/icon-detection.md) |
| [Cache Architecture](./docs/en/cache-architecture.md) | [缓存架构设计](./docs/zh-CN/cache-architecture.md) |
### Research Documents
| English | 中文 |
| ------------------------------------------------------------------- | --------------------------------------------------------- |
| [Grid Layout Research](./docs/en/grid-layout-research.md) | [Grid 布局研究](./docs/zh-CN/grid-layout-research.md) |
| [Layout Detection Research](./docs/en/layout-detection-research.md) | [布局检测研究](./docs/zh-CN/layout-detection-research.md) |
### Architecture Documents
| English | 中文 |
| ----------------------------------------- | ---------------------------------------- |
| [Architecture](./docs/en/architecture.md) | [系统架构](./docs/zh-CN/architecture.md) |
---
## Command Line Options
| Option | Description | Default |
| ----------------- | ------------------------- | -------- |
| `--figma-api-key` | Figma API token | Required |
| `--port` | Server port for HTTP mode | 3333 |
| `--stdio` | Run in stdio mode | false |
| `--help` | Show help | - |
---
## Contributing
Contributions are welcome!
```bash
# Setup
git clone https://github.com/1yhy/Figma-Context-MCP.git
cd Figma-Context-MCP
pnpm install
# Development
pnpm dev # Watch mode
pnpm test # Run tests (272 test cases)
pnpm lint # Lint code
pnpm build # Build
# Debug
pnpm inspect # MCP Inspector
# Test with your own Figma data
pnpm tsx scripts/fetch-test-data.ts <fileKey> <nodeId> <outputName>
# Commit (uses conventional commits)
git commit -m "feat: add new feature"
```
### Commit Types
| Type | Description |
| ---------- | ------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation |
| `style` | Code style |
| `refactor` | Refactoring |
| `test` | Tests |
| `chore` | Maintenance |
### Release Process (Maintainers)
```bash
# 1. Update version in package.json and CHANGELOG.md
# 2. Commit version bump
git add -A
git commit -m "chore: bump version to x.x.x"
# 3. Publish to npm (auto runs: type-check → lint → test → build)
npm login --scope=@yhy2001 # if not logged in
pnpm run pub:release
# 4. Create git tag and push
git tag vx.x.x
git push origin main --tags
# 5. Create GitHub Release (optional)
# Go to https://github.com/1yhy/Figma-Context-MCP/releases/new
```
### Testing with Your Own Figma Data
You can test the layout detection and optimization with your own Figma designs:
#### 1. Configure Environment Variables
```bash
# Copy the environment template
cp .env.example .env
# Edit .env file with your configuration
FIGMA_API_KEY=your_figma_api_key_here
TEST_FIGMA_FILE_KEY=your_file_key # Optional
TEST_FIGMA_NODE_ID=your_node_id # Optional
```
#### 2. Fetch Figma Node Data
```bash
# Method 1: Using command line arguments (recommended)
pnpm tsx scripts/fetch-test-data.ts <fileKey> <nodeId> <outputName>
# Example: Fetch a specific node
pnpm tsx scripts/fetch-test-data.ts UgtwrncR3GokKDIS7dpm4Z 402-34955 my-design
# Method 2: Using environment variables
TEST_FIGMA_FILE_KEY=xxx TEST_FIGMA_NODE_ID=123-456 pnpm tsx scripts/fetch-test-data.ts
```
**Parameters:**
| Parameter | Description | How to Get |
| ------------ | --------------------- | ------------------------------------------------------------ |
| `fileKey` | Figma file identifier | Part after `/design/` in URL, e.g., `UgtwrncR3GokKDIS7dpm4Z` |
| `nodeId` | Node ID | `node-id=` parameter in URL, e.g., `402-34955` |
| `outputName` | Output filename | Custom name, e.g., `my-design` |
**Example URL Parsing:**
```
https://www.figma.com/design/UgtwrncR3GokKDIS7dpm4Z/MyProject?node-id=402-34955
↑ fileKey ↑ nodeId
```
#### 3. Run Tests to Validate Output
```bash
# Run all tests
pnpm test
# Run only integration tests (validate layout optimization)
pnpm test tests/integration/
# View output JSON files
ls tests/fixtures/figma-data/
```
#### 4. Analyze Optimization Results
Tests automatically validate:
- **Data Compression** - Typically >50% compression
- **Layout Detection** - Flex/Grid layout recognition rate
- **CSS Properties** - Redundant property cleanup
- **Output Quality** - Structural consistency checks
If tests fail, the output may not meet expectations. Check error messages to adjust or report an issue.
---
## License
[MIT](./LICENSE) © 1yhy
## Acknowledgments
- [Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) - Original project
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
- [Best-README-Template](https://github.com/othneildrew/Best-README-Template) - README template reference
---
<p align="center">
Made with ❤️ for the AI coding community
</p>
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
# Contributing to Figma Context MCP
Thank you for your interest in contributing to Figma Context MCP! This document provides guidelines and instructions for contributing.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
- [Code Style](#code-style)
## Code of Conduct
Please be respectful and constructive in all interactions. We welcome contributors from all backgrounds and experience levels.
## Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/Figma-Context-MCP.git`
3. Add upstream remote: `git remote add upstream https://github.com/1yhy/Figma-Context-MCP.git`
## Development Setup
### Prerequisites
- Node.js >= 18.0.0
- pnpm (recommended) or npm
### Installation
```bash
# Install dependencies
pnpm install
# Build the project
pnpm build
# Run in development mode (with watch)
pnpm dev
```
### Running Tests
```bash
# Run all tests
pnpm test
# Run specific test suites
pnpm test:layout # Layout detection tests
pnpm test:icon # Icon detection tests
pnpm test:all # All tests including final output
```
### Code Quality
```bash
# Type checking
pnpm type-check
# Linting
pnpm lint
pnpm lint:fix
# Formatting
pnpm format
```
## Making Changes
1. Create a new branch from `main`:
```bash
git checkout -b feat/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
2. Make your changes following the code style guidelines
3. Test your changes locally
4. Commit your changes following the commit guidelines
## Commit Guidelines
We use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages.
### Format
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Types
| Type | Description |
| ---------- | -------------------------------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Code style (formatting, missing semi-colons, etc.) |
| `refactor` | Code refactoring (no functional changes) |
| `perf` | Performance improvement |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks |
### Examples
```bash
feat(layout): add support for grid layout detection
fix(parser): handle empty node arrays correctly
docs: update installation instructions
refactor(core): simplify node processing logic
```
## Pull Request Process
1. Update documentation if needed
2. Ensure all tests pass: `pnpm test`
3. Ensure code quality checks pass: `pnpm lint && pnpm type-check`
4. Fill out the pull request template completely
5. Request review from maintainers
### PR Title
Use the same format as commit messages:
```
feat(layout): add support for grid layout detection
```
## Code Style
### TypeScript
- Use TypeScript strict mode
- Prefer `interface` over `type` for object shapes
- Use explicit return types for public functions
- Avoid `any` - use `unknown` if type is truly unknown
### Formatting
- We use Prettier for code formatting
- Run `pnpm format` before committing
- EditorConfig is provided for consistent editor settings
### File Organization
```
src/
├── algorithms/ # Detection algorithms (layout, icon)
├── core/ # Core parsing logic
├── services/ # External services (Figma API, cache)
├── types/ # TypeScript type definitions
└── utils/ # Utility functions
```
### Naming Conventions
| Type | Convention | Example |
| ---------- | ------------------------------------- | -------------------- |
| Files | kebab-case | `layout-detector.ts` |
| Classes | PascalCase | `LayoutOptimizer` |
| Functions | camelCase | `detectLayout()` |
| Constants | UPPER_SNAKE_CASE | `MAX_ICON_SIZE` |
| Interfaces | PascalCase with `I` prefix (optional) | `SimplifiedNode` |
## Questions?
If you have questions, feel free to:
- Open an issue with the "question" label
- Check existing issues and discussions
Thank you for contributing!
```
--------------------------------------------------------------------------------
/src/prompts/index.ts:
--------------------------------------------------------------------------------
```typescript
export {
DESIGN_TO_CODE_PROMPT,
COMPONENT_ANALYSIS_PROMPT,
STYLE_EXTRACTION_PROMPT,
} from "./design-to-code.js";
```
--------------------------------------------------------------------------------
/src/services/cache.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Cache Service
*
* Re-exports from the cache module for convenience.
*
* @module services/cache
*/
export {
CacheManager,
cacheManager,
type CacheConfig,
type CacheStatistics,
} from "./cache/index.js";
```
--------------------------------------------------------------------------------
/src/resources/index.ts:
--------------------------------------------------------------------------------
```typescript
export {
getFileMetadata,
getStyleTokens,
getComponentList,
getAssetList,
createFileMetadataTemplate,
createStylesTemplate,
createComponentsTemplate,
createAssetsTemplate,
FIGMA_MCP_HELP,
type FileMetadata,
type StyleTokens,
type ComponentSummary,
} from "./figma-resources.js";
```
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
```typescript
import { defineConfig } from "tsup";
const isDev = process.env.npm_lifecycle_event === "dev";
export default defineConfig({
clean: true,
entry: ["src/index.ts"],
format: ["esm"],
minify: !isDev,
target: "esnext",
outDir: "dist",
outExtension: ({ format }) => ({
js: ".js",
}),
onSuccess: isDev ? "node dist/index.js" : undefined,
});
```
--------------------------------------------------------------------------------
/src/algorithms/icon/index.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Icon Detection Algorithm Module
*
* Exports the icon detection algorithm for identifying and merging
* fragmented icon layers in Figma designs.
*
* @module algorithms/icon
*/
export {
detectIcon,
processNodeTree,
collectExportableIcons,
analyzeNodeTree,
DEFAULT_CONFIG,
type FigmaNode,
type IconDetectionResult,
type DetectionConfig,
} from "./detector.js";
```
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
```typescript
import { defineConfig } from "vitest/config";
import { resolve } from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/index.ts", "src/types/**"],
},
testTimeout: 10000,
},
resolve: {
alias: {
"~": resolve(__dirname, "./src"),
},
},
});
```
--------------------------------------------------------------------------------
/src/services/simplify-node-response.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Simplified Node Response Service
*
* This module re-exports the core parser functionality for backward compatibility.
* The actual parsing logic has been moved to ~/core/parser.ts.
*
* @module services/simplify-node-response
*/
// Re-export parser function
export { parseFigmaResponse } from "~/core/parser.js";
// Re-export types for backward compatibility
export type {
CSSStyle,
TextStyle,
SimplifiedDesign,
SimplifiedNode,
SimplifiedFill,
ExportInfo,
ImageResource,
FigmaNodeType,
} from "~/types/index.js";
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
FROM node:lts-alpine
# Install pnpm globally
RUN npm install -g pnpm
# Set working directory
WORKDIR /app
# Copy package files and install dependencies (cache layer)
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
# Copy all source files
COPY . .
# Build the project
RUN pnpm run build
# Install this package globally so that the 'figma-mcp' command is available
RUN npm install -g .
# Expose the port (default 3333)
EXPOSE 3333
# Default command to run the MCP server
CMD [ "figma-mcp", "--stdio" ]
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"]
},
"target": "ES2020",
"lib": ["ES2021", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
/* EMIT RULES */
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*", "tests/**/*"]
}
```
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Figma Context MCP - Type Definitions
*
* Central export point for all type definitions.
* Types are organized into separate modules by domain.
*
* @module types
*/
// Figma API types
export type {
FigmaNodeType,
ImageResource,
ExportFormat,
ExportInfo,
FigmaError,
RateLimitInfo,
FetchImageParams,
FetchImageFillParams,
} from "./figma.js";
// Simplified output types
export type {
CSSHexColor,
CSSRGBAColor,
CSSStyle,
TextStyle,
SimplifiedFill,
SimplifiedNode,
SimplifiedDesign,
IconDetectionResult,
IconDetectionConfig,
LayoutInfo,
} from "./simplified.js";
```
--------------------------------------------------------------------------------
/src/services/cache/index.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Cache Module
*
* Multi-layer caching system for Figma data.
*
* @module services/cache
*/
// Types
export type {
CacheConfig,
MemoryCacheConfig,
DiskCacheConfig,
CacheEntryMeta,
NodeCacheEntry,
ImageCacheEntry,
MemoryCacheStats,
DiskCacheStats,
CacheStatistics,
} from "./types.js";
export { DEFAULT_MEMORY_CONFIG, DEFAULT_DISK_CONFIG, DEFAULT_CACHE_CONFIG } from "./types.js";
// LRU Cache
export { LRUCache, NodeLRUCache, type LRUCacheConfig, type CacheStats } from "./lru-cache.js";
// Disk Cache
export { DiskCache } from "./disk-cache.js";
// Cache Manager
export { CacheManager, cacheManager } from "./cache-manager.js";
```
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
```javascript
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"feat", // New feature
"fix", // Bug fix
"docs", // Documentation
"style", // Code style (formatting, etc.)
"refactor", // Code refactoring
"perf", // Performance improvement
"test", // Tests
"build", // Build system
"ci", // CI configuration
"chore", // Maintenance
"revert", // Revert commit
],
],
"subject-case": [0], // Disable case checking for Chinese commits
"header-max-length": [2, "always", 100],
},
};
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
required:
- figmaApiKey
properties:
figmaApiKey:
type: string
description: Your Figma API access token
port:
type: number
default: 3333
description: Port for the server to run on (default 3333)
commandFunction:
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|-
(config) => ({
command: 'figma-mcp',
args: [`--figma-api-key=${config.figmaApiKey}`, '--stdio', `--port=${config.port}`],
env: {}
})
exampleConfig:
figmaApiKey: dummy-figma-api-key
port: 3333
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { FigmaMcpServer } from "./server.js";
import { getServerConfig } from "./config.js";
import { resolve } from "path";
import { config } from "dotenv";
// Load .env from the current working directory
config({ path: resolve(process.cwd(), ".env") });
export async function startServer(): Promise<void> {
// Check if we're running in stdio mode (e.g., via CLI)
const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio");
const config = getServerConfig(isStdioMode);
const server = new FigmaMcpServer(config.figmaApiKey);
if (isStdioMode) {
const transport = new StdioServerTransport();
await server.connect(transport);
} else {
console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`);
await server.startHttpServer(config.port);
}
}
startServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
```markdown
## Description
<!-- Describe your changes in detail -->
## Related Issue
<!-- Link to the issue this PR addresses (if applicable) -->
<!-- Fixes #123 -->
## Type of Change
<!-- Mark the relevant option with an "x" -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have added/updated comments for hard-to-understand areas
- [ ] I have updated the documentation (if applicable)
- [ ] My changes generate no new warnings
- [ ] I have run `pnpm lint` and `pnpm build` successfully
- [ ] I have tested my changes locally
## Screenshots (if applicable)
<!-- Add screenshots to help explain your changes -->
## Additional Notes
<!-- Any additional information that reviewers should know -->
```
--------------------------------------------------------------------------------
/src/core/style.ts:
--------------------------------------------------------------------------------
```typescript
import { type Node as FigmaDocumentNode } from "@figma/rest-api-spec";
import type { SimplifiedFill } from "~/types/index.js";
import { generateCSSShorthand } from "~/utils/css.js";
import { isVisible } from "~/utils/validation.js";
import { parsePaint } from "~/utils/color.js";
import { hasValue, isStrokeWeights } from "~/utils/validation.js";
export type SimplifiedStroke = {
colors: SimplifiedFill[];
strokeWeight?: string;
strokeDashes?: number[];
strokeWeights?: string;
};
export function buildSimplifiedStrokes(n: FigmaDocumentNode): SimplifiedStroke {
const strokes: SimplifiedStroke = { colors: [] };
if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
strokes.colors = n.strokes.filter(isVisible).map(parsePaint);
}
if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
strokes.strokeWeight = `${n.strokeWeight}px`;
}
if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
strokes.strokeDashes = n.strokeDashes;
}
if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
}
return strokes;
}
```
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
ignores: ["dist/**", "node_modules/**", "*.config.js"],
},
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
// TypeScript specific
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
// General
"no-console": ["warn", { allow: ["warn", "error"] }],
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
},
},
// Test files - allow console.log and non-null assertions
{
files: ["tests/**/*.ts"],
rules: {
"no-console": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
```yaml
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please fill out the form below.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see.
placeholder: "I would like to be able to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or features you've considered.
placeholder: "I've also thought about..."
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Nice to have
- Important
- Critical
validations:
required: true
- type: checkboxes
id: contribution
attributes:
label: Contribution
description: Are you willing to contribute to this feature?
options:
- label: I'm willing to submit a PR for this feature
```
--------------------------------------------------------------------------------
/src/algorithms/layout/index.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Layout Detection Algorithm Module
*
* Exports spatial analysis utilities for detecting layout patterns
* in Figma designs, including row/column grouping and containment analysis.
*
* @module algorithms/layout
*/
// Spatial analysis utilities
export {
RectUtils,
SpatialProjectionAnalyzer,
NodeRelationship,
type Rect,
type ProjectionLine,
} from "./spatial.js";
// Layout optimizer
export { LayoutOptimizer } from "./optimizer.js";
// Layout detection algorithm
export {
// Types
type BoundingBox,
type ElementRect,
type LayoutGroup,
type LayoutAnalysisResult,
type LayoutNode,
type GridAnalysisResult,
type OverlapType,
type OverlapDetectionResult,
// Bounding box utilities
extractBoundingBox,
toElementRect,
calculateBounds,
// Overlap detection
isOverlappingY,
isOverlappingX,
isFullyOverlapping,
calculateIoU,
classifyOverlap,
detectOverlappingElements,
// Background element detection
detectBackgroundElement,
type BackgroundDetectionResult,
// Grouping
groupIntoRows,
groupIntoColumns,
findOverlappingElements,
// Gap analysis
calculateGaps,
analyzeGaps,
roundToCommonValue,
// Alignment
areValuesAligned,
analyzeAlignment,
toJustifyContent,
toAlignItems,
// Layout detection
detectLayoutDirection,
analyzeLayout,
buildLayoutTree,
generateLayoutReport,
// Grid detection
clusterValues,
detectGridLayout,
// Homogeneity analysis
analyzeHomogeneity,
filterHomogeneousForGrid,
type HomogeneityResult,
} from "./detector.js";
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm type-check
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
release:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
# Uncomment to enable auto-publish on main branch
# - name: Publish to npm
# run: npm publish --access public
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```
--------------------------------------------------------------------------------
/tests/utils/preview.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Generate simplified output and open viewer for comparison
*
* Usage: pnpm preview
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { exec } from "child_process";
import { parseFigmaResponse } from "../../src/services/simplify-node-response.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.join(__dirname, "../fixtures");
const originalPath = path.join(fixturesDir, "figma-data/real-node-data.json");
const outputPath = path.join(__dirname, "simplified-with-css.json");
const viewerPath = path.join(__dirname, "viewer.html");
async function main() {
console.log("📖 Reading original Figma data...");
const originalData = JSON.parse(fs.readFileSync(originalPath, "utf-8"));
console.log("⚙️ Running simplification...");
const simplified = parseFigmaResponse(originalData);
console.log("💾 Saving output...");
fs.writeFileSync(outputPath, JSON.stringify(simplified, null, 2));
const originalSize = fs.statSync(originalPath).size;
const simplifiedSize = fs.statSync(outputPath).size;
const compressionRate = ((1 - simplifiedSize / originalSize) * 100).toFixed(1);
console.log("");
console.log("📊 Results:");
console.log(` Original: ${(originalSize / 1024).toFixed(1)} KB`);
console.log(` Simplified: ${(simplifiedSize / 1024).toFixed(1)} KB`);
console.log(` Compression: ${compressionRate}%`);
console.log("");
// Open viewer in browser
console.log("🌐 Opening viewer...");
const openCommand =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
exec(`${openCommand} "${viewerPath}"`, (error) => {
if (error) {
console.log(` Viewer path: file://${viewerPath}`);
console.log(" (Please open manually in browser)");
} else {
console.log(" Viewer opened in browser");
}
});
}
main().catch(console.error);
```
--------------------------------------------------------------------------------
/src/utils/file.ts:
--------------------------------------------------------------------------------
```typescript
// ==================== Name Processing ====================
/**
* Clean illegal characters from name (for file names)
*
* @param name Original name
* @param separator Separator, defaults to '_'
* @returns Sanitized name
*/
export function sanitizeName(name: string, separator: string = "_"): string {
return name
.replace(/[/\\?%*:|"<>]/g, separator) // Replace illegal filesystem characters
.replace(/\s+/g, separator) // Replace whitespace characters
.replace(new RegExp(`${separator}+`, "g"), separator) // Merge consecutive separators
.toLowerCase();
}
/**
* Clean name for ID generation (only keep alphanumeric and hyphens)
*
* @param name Original name
* @returns Sanitized name
*/
export function sanitizeNameForId(name: string): string {
return name
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-]/g, "")
.toLowerCase();
}
/**
* Generate file name based on node name
*/
export function generateFileName(name: string, format: string): string {
const sanitizedName = sanitizeName(name, "_");
const lowerFormat = format.toLowerCase();
// If the name already includes the extension, keep the original name
if (sanitizedName.includes(`.${lowerFormat}`)) {
return sanitizedName;
}
return `${sanitizedName}.${lowerFormat}`;
}
// ==================== Export Format Detection ====================
/**
* Node interface for format detection
*/
export interface FormatDetectionNode {
type?: string;
exportSettings?: { format?: string[] };
cssStyles?: { backgroundImage?: string };
exportInfo?: { format?: string };
children?: FormatDetectionNode[];
}
/**
* Choose appropriate export format based on node characteristics
*
* @param node Node object
* @param isSVGNode SVG detection function
* @returns Recommended export format
*/
export function suggestExportFormat(
node: FormatDetectionNode,
isSVGNode: (node: FormatDetectionNode) => boolean,
): "PNG" | "JPG" | "SVG" {
if (isSVGNode(node)) {
return "SVG";
}
return "PNG";
}
```
--------------------------------------------------------------------------------
/src/types/figma.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Figma API Type Definitions
*
* Types related to Figma API interactions, including node types,
* export formats, and API response structures.
*
* @module types/figma
*/
// ==================== Node Types ====================
/** Figma node types */
export type FigmaNodeType =
| "DOCUMENT"
| "CANVAS"
| "FRAME"
| "GROUP"
| "TEXT"
| "VECTOR"
| "RECTANGLE"
| "ELLIPSE"
| "LINE"
| "POLYGON"
| "STAR"
| "BOOLEAN_OPERATION"
| "REGULAR_POLYGON"
| "INSTANCE"
| "COMPONENT"
| string;
/**
* Image resource reference
*/
export interface ImageResource {
/** Image reference ID for downloading */
imageRef: string;
}
// ==================== Export Types ====================
/**
* Export format options
*/
export type ExportFormat = "PNG" | "JPG" | "SVG";
/**
* Export information for image nodes
*/
export interface ExportInfo {
/** Export type (single image or image group) */
type: "IMAGE" | "IMAGE_GROUP";
/** Recommended export format */
format: ExportFormat;
/** Node ID for API calls (optional, defaults to node.id) */
nodeId?: string;
/** Suggested file name */
fileName?: string;
}
// ==================== API Types ====================
/**
* Figma API error
*/
export interface FigmaError {
status: number;
err: string;
rateLimitInfo?: RateLimitInfo;
}
/**
* Rate limit information from Figma API
*/
export interface RateLimitInfo {
/** Remaining requests */
remaining: number | null;
/** Reset time in seconds */
resetAfter: number | null;
/** Retry wait time in seconds */
retryAfter: number | null;
}
/**
* Image download parameters
*/
export interface FetchImageParams {
/** Figma node ID */
nodeId: string;
/** Local file name */
fileName: string;
/** File format */
fileType: "png" | "svg";
}
/**
* Image fill download parameters
*/
export interface FetchImageFillParams {
/** Node ID */
nodeId: string;
/** Local file name */
fileName: string;
/** Image reference ID */
imageRef: string;
}
```
--------------------------------------------------------------------------------
/src/core/effects.ts:
--------------------------------------------------------------------------------
```typescript
import {
type DropShadowEffect,
type InnerShadowEffect,
type BlurEffect,
type Node as FigmaDocumentNode,
} from "@figma/rest-api-spec";
import { formatRGBAColor } from "~/utils/color.js";
import { hasValue } from "~/utils/validation.js";
export type SimplifiedEffects = {
boxShadow?: string;
filter?: string;
backdropFilter?: string;
};
export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects {
if (!hasValue("effects", n)) return {};
const effects = n.effects.filter((e) => e.visible);
// Handle drop and inner shadows (both go into CSS box-shadow)
const dropShadows = effects
.filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW")
.map(simplifyDropShadow);
const innerShadows = effects
.filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW")
.map(simplifyInnerShadow);
const boxShadow = [...dropShadows, ...innerShadows].join(", ");
// Handle blur effects - separate by CSS property
// Layer blurs use the CSS 'filter' property
const filterBlurValues = effects
.filter((e): e is BlurEffect => e.type === "LAYER_BLUR")
.map(simplifyBlur)
.join(" ");
// Background blurs use the CSS 'backdrop-filter' property
const backdropFilterValues = effects
.filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR")
.map(simplifyBlur)
.join(" ");
const result: SimplifiedEffects = {};
if (boxShadow) result.boxShadow = boxShadow;
if (filterBlurValues) result.filter = filterBlurValues;
if (backdropFilterValues) result.backdropFilter = backdropFilterValues;
return result;
}
function simplifyDropShadow(effect: DropShadowEffect) {
return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
}
function simplifyInnerShadow(effect: InnerShadowEffect) {
return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
}
function simplifyBlur(effect: BlurEffect) {
return `blur(${effect.radius}px)`;
}
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
import { config } from "dotenv";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
// Load environment variables from .env file
config();
interface ServerConfig {
figmaApiKey: string;
port: number;
configSources: {
figmaApiKey: "cli" | "env";
port: "cli" | "env" | "default";
};
}
function maskApiKey(key: string): string {
if (key.length <= 4) return "****";
return `****${key.slice(-4)}`;
}
interface CliArgs {
"figma-api-key"?: string;
port?: number;
}
export function getServerConfig(isStdioMode: boolean): ServerConfig {
// Parse command line arguments
const argv = yargs(hideBin(process.argv))
.options({
"figma-api-key": {
type: "string",
description: "Figma API key",
},
port: {
type: "number",
description: "Port to run the server on",
},
})
.help()
.version("0.1.12")
.parseSync() as CliArgs;
const config: ServerConfig = {
figmaApiKey: "",
port: 3333,
configSources: {
figmaApiKey: "env",
port: "default",
},
};
// Handle FIGMA_API_KEY
if (argv["figma-api-key"]) {
config.figmaApiKey = argv["figma-api-key"];
config.configSources.figmaApiKey = "cli";
} else if (process.env.FIGMA_API_KEY) {
config.figmaApiKey = process.env.FIGMA_API_KEY;
config.configSources.figmaApiKey = "env";
}
// Handle PORT
if (argv.port) {
config.port = argv.port;
config.configSources.port = "cli";
} else if (process.env.PORT) {
config.port = parseInt(process.env.PORT, 10);
config.configSources.port = "env";
}
// Validate configuration
if (!config.figmaApiKey) {
console.error("FIGMA_API_KEY is required (via CLI argument --figma-api-key or .env file)");
process.exit(1);
}
// Log configuration sources
if (!isStdioMode) {
console.log("\nConfiguration:");
console.log(
`- FIGMA_API_KEY: ${maskApiKey(config.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`,
);
console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`);
console.log(); // Empty line for better readability
}
return config;
}
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
```yaml
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Configure MCP server with...
2. Open Figma file...
3. Run command...
4. See error...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: input
id: version
attributes:
label: Package Version
description: What version of figma-mcp-server are you using?
placeholder: "1.0.0"
validations:
required: true
- type: dropdown
id: client
attributes:
label: AI Client
description: Which AI client are you using?
options:
- Cursor
- Windsurf
- Cline
- Claude Desktop
- Other
validations:
required: true
- type: input
id: node-version
attributes:
label: Node.js Version
description: What version of Node.js are you using? (run `node -v`)
placeholder: "v20.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: If applicable, paste any error logs here.
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem.
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2025-01-06
### Added
- **Grid Layout Detection** - Automatically detect and convert grid-like arrangements to CSS Grid
- **Background Element Merging** - Smart merge of background layers with padding inference
- **Multi-layer Cache System** - LRU memory cache (L1) + disk cache (L2) for 24h data persistence
- **MCP Resources** - Lightweight resource endpoints (`figma://file`, `/styles`, `/components`, `/assets`) for token-efficient metadata browsing
- **MCP Prompts** - Professional `design_to_code` prompt for guided AI code generation workflow
- **Comprehensive Test Suite** - 272 tests covering layout optimization, icon detection, parser, and resources (Vitest)
### Changed
- **Improved Flexbox Detection** - Enhanced stack detection with better gap/padding inference
- **Icon Detection Optimization** - Single-pass tree traversal for better performance
- **Modular Architecture** - Reorganized codebase (`transformers` → `core/algorithms`) for better maintainability
- **Bilingual Documentation** - Complete English and Chinese docs for all algorithms and architecture
### Fixed
- **Gradient Alpha Channel** - Preserve alpha values in gradient color stops
- **Non-grid Element Positioning** - Correct position handling for elements outside grid containers
- **Security Dependencies** - Updated dependencies to resolve vulnerabilities
## [1.0.1] - 2024-12-05
### Added
- Smart layout detection algorithm (Flexbox inference from absolute positioning)
- Icon layer merge algorithm (reduces fragmented exports by 96%)
- CSS generation with optimized output
- HTML preview generation from Figma JSON
- Comprehensive documentation for algorithms
### Changed
- Optimized data structures for AI consumption
- Reduced output size by ~87% through intelligent simplification
- Improved node processing with better type handling
### Fixed
- Round all px values to integers
- Proper handling of gradient and image fills
- Border style extraction improvements
## [1.0.0] - 2024-12-01
### Added
- Initial release
- MCP server implementation for Figma integration
- `get_figma_data` tool for fetching design data
- `download_figma_images` tool for image export
- Support for stdio and HTTP/SSE modes
- Basic CSS style generation
- Figma API integration with caching
[1.1.0]: https://github.com/1yhy/Figma-Context-MCP/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/1yhy/Figma-Context-MCP/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/1yhy/Figma-Context-MCP/releases/tag/v1.0.0
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@yhy2001/figma-mcp-server",
"version": "1.1.0",
"description": "MCP server for Figma design integration with AI coding tools",
"type": "module",
"main": "dist/index.js",
"bin": {
"figma-mcp": "dist/index.js"
},
"files": [
"dist",
"README.md",
"README.zh-CN.md"
],
"scripts": {
"dev": "cross-env NODE_ENV=development tsup --watch",
"build": "tsup",
"start": "node dist/index.js",
"start:cli": "cross-env NODE_ENV=cli node dist/index.js",
"start:http": "node dist/index.js",
"dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio",
"type-check": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"preview": "tsx tests/utils/preview.ts",
"inspect": "pnpx @modelcontextprotocol/inspector",
"mcp-test": "pnpm start -- --stdio",
"prepublishOnly": "pnpm type-check && pnpm lint && pnpm test && pnpm build",
"pub:release": "pnpm build && npm publish --access public",
"publish:local": "pnpm build && npm pack",
"prepare": "husky || true"
},
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/1yhy/Figma-Context-MCP.git"
},
"homepage": "https://github.com/1yhy/Figma-Context-MCP#readme",
"bugs": {
"url": "https://github.com/1yhy/Figma-Context-MCP/issues"
},
"keywords": [
"figma",
"mcp",
"model-context-protocol",
"typescript",
"ai",
"design",
"cursor",
"windsurf",
"cline",
"design-to-code"
],
"author": "1yhy",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.3",
"cross-env": "^7.0.3",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"remeda": "^2.20.1",
"yargs": "^17.7.2",
"zod": "^4.1.13"
},
"devDependencies": {
"@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^19.0.0",
"@eslint/js": "^9.39.1",
"@figma/rest-api-spec": "^0.24.0",
"@types/express": "^5.0.0",
"@types/node": "^24.10.1",
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitest/coverage-v8": "^4.0.15",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.0.1",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"prettier": "^3.5.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.48.1",
"vitest": "^4.0.15"
}
}
```
--------------------------------------------------------------------------------
/src/utils/color.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Color Conversion Utilities
*
* Functions for converting between Figma color formats and CSS color values.
*
* @module utils/color
*/
import type { Paint, RGBA } from "@figma/rest-api-spec";
import type { CSSHexColor, CSSRGBAColor, SimplifiedFill } from "~/types/index.js";
// ==================== Type Definitions ====================
export interface ColorValue {
hex: CSSHexColor;
opacity: number;
}
// ==================== Color Conversion ====================
/**
* Convert hex color and opacity to rgba format
*/
export function hexToRgba(hex: string, opacity: number = 1): string {
hex = hex.replace("#", "");
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const validOpacity = Math.min(Math.max(opacity, 0), 1);
return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
}
/**
* Convert Figma RGBA color to { hex, opacity }
*/
export function convertColor(color: RGBA, opacity = 1): ColorValue {
const r = Math.round(color.r * 255);
const g = Math.round(color.g * 255);
const b = Math.round(color.b * 255);
const a = Math.round(opacity * color.a * 100) / 100;
const hex = ("#" +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor;
return { hex, opacity: a };
}
/**
* Convert Figma RGBA to CSS rgba() format
*/
export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
const r = Math.round(color.r * 255);
const g = Math.round(color.g * 255);
const b = Math.round(color.b * 255);
const a = Math.round(opacity * color.a * 100) / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
// ==================== Fill Parsing ====================
/**
* Convert Figma Paint to simplified fill format
*/
export function parsePaint(raw: Paint): SimplifiedFill {
if (raw.type === "IMAGE") {
const imagePaint = raw as { type: "IMAGE"; imageRef?: string; scaleMode?: string };
return {
type: "IMAGE",
imageRef: imagePaint.imageRef,
scaleMode: imagePaint.scaleMode,
};
}
if (raw.type === "SOLID") {
const { hex, opacity } = convertColor(raw.color!, raw.opacity);
if (opacity === 1) {
return hex;
}
return formatRGBAColor(raw.color!, opacity);
}
if (
raw.type === "GRADIENT_LINEAR" ||
raw.type === "GRADIENT_RADIAL" ||
raw.type === "GRADIENT_ANGULAR" ||
raw.type === "GRADIENT_DIAMOND"
) {
const gradientPaint = raw as {
type: typeof raw.type;
gradientHandlePositions?: Array<{ x: number; y: number }>;
gradientStops?: Array<{ position: number; color: RGBA }>;
};
return {
type: raw.type,
gradientHandlePositions: gradientPaint.gradientHandlePositions,
gradientStops: gradientPaint.gradientStops?.map(({ position, color }) => ({
position,
color: convertColor(color).hex,
})),
};
}
throw new Error(`Unknown paint type: ${raw.type}`);
}
```
--------------------------------------------------------------------------------
/src/services/cache/types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Cache System Type Definitions
*
* @module services/cache/types
*/
// ==================== Configuration ====================
/**
* Memory cache configuration
*/
export interface MemoryCacheConfig {
/** Maximum number of node cache items (default: 100) */
maxNodeItems: number;
/** Maximum number of image cache items (default: 50) */
maxImageItems: number;
/** Node cache TTL in milliseconds (default: 5 minutes) */
nodeTTL: number;
/** Image cache TTL in milliseconds (default: 10 minutes) */
imageTTL: number;
}
/**
* Disk cache configuration
*/
export interface DiskCacheConfig {
/** Cache directory path */
cacheDir: string;
/** Maximum disk cache size in bytes (default: 500MB) */
maxSize: number;
/** Cache TTL in milliseconds (default: 24 hours) */
ttl: number;
}
/**
* Complete cache configuration
*/
export interface CacheConfig {
/** Whether caching is enabled */
enabled: boolean;
/** Memory cache configuration */
memory: MemoryCacheConfig;
/** Disk cache configuration */
disk: DiskCacheConfig;
}
// ==================== Cache Entries ====================
/**
* Cache entry metadata
*/
export interface CacheEntryMeta {
/** Cache key */
key: string;
/** Creation timestamp */
createdAt: number;
/** Expiration timestamp */
expiresAt: number;
/** Figma file key */
fileKey: string;
/** Figma node ID (optional) */
nodeId?: string;
/** Figma file version (lastModified) */
version?: string;
/** Query depth */
depth?: number;
/** Data size in bytes */
size?: number;
}
/**
* Node cache entry
*/
export interface NodeCacheEntry {
/** Cached data */
data: unknown;
/** Figma file key */
fileKey: string;
/** Figma node ID */
nodeId?: string;
/** Figma file version */
version?: string;
/** Query depth */
depth?: number;
}
/**
* Image cache entry
*/
export interface ImageCacheEntry {
/** Local file path */
path: string;
/** Figma file key */
fileKey: string;
/** Figma node ID */
nodeId: string;
/** Image format */
format: string;
/** File size in bytes */
size?: number;
}
// ==================== Statistics ====================
/**
* Memory cache statistics
*/
export interface MemoryCacheStats {
/** Cache hits */
hits: number;
/** Cache misses */
misses: number;
/** Current item count */
size: number;
/** Maximum item count */
maxSize: number;
/** Hit rate (0-1) */
hitRate: number;
/** Eviction count */
evictions: number;
}
/**
* Disk cache statistics
*/
export interface DiskCacheStats {
/** Cache hits */
hits: number;
/** Cache misses */
misses: number;
/** Total size in bytes */
totalSize: number;
/** Maximum size in bytes */
maxSize: number;
/** Node data file count */
nodeFileCount: number;
/** Image file count */
imageFileCount: number;
}
/**
* Combined cache statistics
*/
export interface CacheStatistics {
/** Whether cache is enabled */
enabled: boolean;
/** Memory cache stats */
memory: MemoryCacheStats;
/** Disk cache stats */
disk: DiskCacheStats;
}
// ==================== Default Configurations ====================
/**
* Default memory cache configuration
*/
export const DEFAULT_MEMORY_CONFIG: MemoryCacheConfig = {
maxNodeItems: 100,
maxImageItems: 50,
nodeTTL: 5 * 60 * 1000, // 5 minutes
imageTTL: 10 * 60 * 1000, // 10 minutes
};
/**
* Default disk cache configuration
*/
export const DEFAULT_DISK_CONFIG: Partial<DiskCacheConfig> = {
maxSize: 500 * 1024 * 1024, // 500MB
ttl: 24 * 60 * 60 * 1000, // 24 hours
};
/**
* Default complete cache configuration
*/
export const DEFAULT_CACHE_CONFIG: Omit<CacheConfig, "disk"> & { disk: Partial<DiskCacheConfig> } =
{
enabled: true,
memory: DEFAULT_MEMORY_CONFIG,
disk: DEFAULT_DISK_CONFIG,
};
```
--------------------------------------------------------------------------------
/scripts/optimize-figma-json.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env npx tsx
/**
* Figma JSON Optimizer Script
*
* Usage:
* npx tsx scripts/optimize-figma-json.ts <input-file> [output-file]
*
* Examples:
* npx tsx scripts/optimize-figma-json.ts tests/fixtures/figma-data/node-108-517.json
* npx tsx scripts/optimize-figma-json.ts input.json output-optimized.json
*
* If output-file is not specified:
* - For files in figma-data/, outputs to expected/<name>-optimized.json
* - Otherwise, outputs to <input-name>-optimized.json in the same directory
*/
import fs from "fs";
import path from "path";
import { parseFigmaResponse } from "../src/core/parser.js";
// Parse arguments
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
console.log(`
Figma JSON Optimizer
Usage:
npx tsx scripts/optimize-figma-json.ts <input-file> [output-file]
Examples:
npx tsx scripts/optimize-figma-json.ts tests/fixtures/figma-data/node-108-517.json
npx tsx scripts/optimize-figma-json.ts input.json output-optimized.json
Options:
--help, -h Show this help message
--stats Show detailed statistics only (no file output)
--quiet, -q Minimal output
`);
process.exit(0);
}
const inputFile = args[0];
const showStatsOnly = args.includes("--stats");
const quiet = args.includes("--quiet") || args.includes("-q");
// Determine output file
let outputFile = args.find((arg) => !arg.startsWith("-") && arg !== inputFile);
if (!outputFile && !showStatsOnly) {
const inputDir = path.dirname(inputFile);
const inputName = path.basename(inputFile, ".json");
// If input is in figma-data, output to expected
if (inputDir.includes("figma-data")) {
const expectedDir = inputDir.replace("figma-data", "expected");
outputFile = path.join(expectedDir, `${inputName}-optimized.json`);
} else {
outputFile = path.join(inputDir, `${inputName}-optimized.json`);
}
}
// Check input file exists
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
process.exit(1);
}
// Read and optimize
if (!quiet) {
console.log(`\nOptimizing: ${inputFile}`);
}
const startTime = Date.now();
const rawData = JSON.parse(fs.readFileSync(inputFile, "utf8"));
const optimized = parseFigmaResponse(rawData);
const elapsed = Date.now() - startTime;
// Calculate statistics
const json = JSON.stringify(optimized, null, 2);
const originalSize = fs.statSync(inputFile).size;
const optimizedSize = Buffer.byteLength(json);
const compression = ((1 - optimizedSize / originalSize) * 100).toFixed(1);
const absoluteCount = (json.match(/"position":\s*"absolute"/g) || []).length;
const gridCount = (json.match(/"display":\s*"grid"/g) || []).length;
const flexCount = (json.match(/"display":\s*"flex"/g) || []).length;
const flexRowCount = (json.match(/"flexDirection":\s*"row"/g) || []).length;
const flexColumnCount = (json.match(/"flexDirection":\s*"column"/g) || []).length;
// Get root node info
let rootInfo = "";
if (optimized.nodes && optimized.nodes.length > 0) {
const root = optimized.nodes[0];
rootInfo = `${root.name} (${root.cssStyles?.width} × ${root.cssStyles?.height})`;
}
// Output results
if (!quiet) {
console.log(`\n${"─".repeat(50)}`);
console.log(`Root: ${rootInfo}`);
console.log(`${"─".repeat(50)}`);
console.log(`\nLayout Statistics:`);
console.log(` position:absolute ${absoluteCount}`);
console.log(` display:grid ${gridCount}`);
console.log(` display:flex ${flexCount} (row: ${flexRowCount}, column: ${flexColumnCount})`);
console.log(`\nCompression:`);
console.log(` Original: ${(originalSize / 1024).toFixed(1)} KB`);
console.log(` Optimized: ${(optimizedSize / 1024).toFixed(1)} KB`);
console.log(` Reduced: ${compression}%`);
console.log(`\nTime: ${elapsed}ms`);
}
// Save output
if (!showStatsOnly && outputFile) {
// Ensure output directory exists
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, json);
if (!quiet) {
console.log(`\nSaved: ${outputFile}`);
} else {
console.log(outputFile);
}
}
if (!quiet) {
console.log("");
}
```
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Validation Utilities
*
* Type guards, validators, and visibility checks for Figma nodes.
*
* @module utils/validation
*/
import type {
Rectangle,
HasLayoutTrait,
StrokeWeights,
HasFramePropertiesTrait,
} from "@figma/rest-api-spec";
import { isTruthy } from "remeda";
import type { CSSHexColor, CSSRGBAColor } from "~/types/index.js";
export { isTruthy };
// ==================== Visibility Types ====================
/** Properties for visibility checking */
export interface VisibilityProperties {
visible?: boolean;
opacity?: number;
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null;
}
/** Properties for parent container clipping check */
export interface ParentClipProperties {
clipsContent?: boolean;
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
}
// ==================== Visibility Checks ====================
/**
* Check if an element is visible
*/
export function isVisible(element: VisibilityProperties): boolean {
if (element.visible === false) {
return false;
}
if (element.opacity === 0) {
return false;
}
if (element.absoluteRenderBounds === null) {
return false;
}
return true;
}
/**
* Check if an element is visible within its parent container (considering clipping)
*/
export function isVisibleInParent(
element: VisibilityProperties,
parent: ParentClipProperties,
): boolean {
if (!isVisible(element)) {
return false;
}
if (
parent &&
parent.clipsContent === true &&
element.absoluteBoundingBox &&
parent.absoluteBoundingBox
) {
const elementBox = element.absoluteBoundingBox;
const parentBox = parent.absoluteBoundingBox;
const outsideParent =
elementBox.x >= parentBox.x + parentBox.width ||
elementBox.x + elementBox.width <= parentBox.x ||
elementBox.y >= parentBox.y + parentBox.height ||
elementBox.y + elementBox.height <= parentBox.y;
if (outsideParent) {
return false;
}
}
return true;
}
// ==================== Object Processing ====================
/**
* Remove empty arrays and empty objects from an object
*/
export function removeEmptyKeys<T>(input: T): T {
if (typeof input !== "object" || input === null) {
return input;
}
if (Array.isArray(input)) {
return input.map((item) => removeEmptyKeys(item)) as T;
}
const result = {} as T;
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
const value = input[key];
const cleanedValue = removeEmptyKeys(value);
if (
cleanedValue !== undefined &&
!(Array.isArray(cleanedValue) && cleanedValue.length === 0) &&
!(
typeof cleanedValue === "object" &&
cleanedValue !== null &&
Object.keys(cleanedValue).length === 0
)
) {
result[key] = cleanedValue;
}
}
}
return result;
}
// ==================== Type Guards ====================
export function hasValue<K extends PropertyKey, T>(
key: K,
obj: unknown,
typeGuard?: (val: unknown) => val is T,
): obj is Record<K, T> {
const isObject = typeof obj === "object" && obj !== null;
if (!isObject || !(key in obj)) return false;
const val = (obj as Record<K, unknown>)[key];
return typeGuard ? typeGuard(val) : val !== undefined;
}
export function isFrame(val: unknown): val is HasFramePropertiesTrait {
return (
typeof val === "object" &&
!!val &&
"clipsContent" in val &&
typeof val.clipsContent === "boolean"
);
}
export function isLayout(val: unknown): val is HasLayoutTrait {
return (
typeof val === "object" &&
!!val &&
"absoluteBoundingBox" in val &&
typeof val.absoluteBoundingBox === "object" &&
!!val.absoluteBoundingBox &&
"x" in val.absoluteBoundingBox &&
"y" in val.absoluteBoundingBox &&
"width" in val.absoluteBoundingBox &&
"height" in val.absoluteBoundingBox
);
}
export function isStrokeWeights(val: unknown): val is StrokeWeights {
return (
typeof val === "object" &&
val !== null &&
"top" in val &&
"right" in val &&
"bottom" in val &&
"left" in val
);
}
export function isRectangle<T, K extends string>(
key: K,
obj: T,
): obj is T & { [P in K]: Rectangle } {
const recordObj = obj as Record<K, unknown>;
return (
typeof obj === "object" &&
!!obj &&
key in recordObj &&
typeof recordObj[key] === "object" &&
!!recordObj[key] &&
"x" in recordObj[key] &&
"y" in recordObj[key] &&
"width" in recordObj[key] &&
"height" in recordObj[key]
);
}
export function isRectangleCornerRadii(val: unknown): val is number[] {
return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number");
}
export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor {
return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba"));
}
```
--------------------------------------------------------------------------------
/src/types/simplified.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Simplified Output Type Definitions
*
* Types for the MCP simplified output format, including CSS styles,
* simplified node structures, and algorithm configurations.
*
* @module types/simplified
*/
import type { ExportInfo } from "./figma.js";
// ==================== CSS Types ====================
/** CSS hex color format */
export type CSSHexColor = `#${string}`;
/** CSS rgba color format */
export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
/**
* CSS style object containing all supported CSS properties
*/
export type CSSStyle = {
// Text styles
fontFamily?: string;
fontSize?: string;
fontWeight?: string | number;
textAlign?: string;
verticalAlign?: string;
lineHeight?: string;
// Colors and backgrounds
color?: string;
backgroundColor?: string;
background?: string;
backgroundImage?: string;
// Layout
width?: string;
height?: string;
margin?: string;
padding?: string;
position?: string;
top?: string;
right?: string;
bottom?: string;
left?: string;
display?: string;
flexDirection?: string;
justifyContent?: string;
alignItems?: string;
gap?: string;
// Grid layout
gridTemplateColumns?: string;
gridTemplateRows?: string;
rowGap?: string;
columnGap?: string;
justifyItems?: string;
gridColumn?: string;
gridRow?: string;
// Flex item
flexGrow?: string | number;
flexShrink?: string | number;
flexBasis?: string;
flex?: string;
alignSelf?: string;
order?: string | number;
// Borders and radius
border?: string;
borderRadius?: string;
borderWidth?: string;
borderStyle?: string;
borderColor?: string;
borderImage?: string;
borderImageSlice?: string;
// Effects
boxShadow?: string;
filter?: string;
backdropFilter?: string;
opacity?: string;
// Webkit specific
webkitBackgroundClip?: string;
webkitTextFillColor?: string;
backgroundClip?: string;
// Allow additional properties
[key: string]: string | number | undefined;
};
/**
* Text style properties (legacy, for backward compatibility)
*/
export type TextStyle = Partial<{
fontFamily: string;
fontWeight: number;
fontSize: number;
textAlignHorizontal: string;
textAlignVertical: string;
lineHeightPx: number;
}>;
// ==================== Simplified Node Types ====================
/**
* Fill type for simplified nodes
*/
export type SimplifiedFill =
| CSSHexColor
| CSSRGBAColor
| SimplifiedSolidFill
| SimplifiedGradientFill
| SimplifiedImageFill;
/** Solid fill with explicit type */
export interface SimplifiedSolidFill {
type: "SOLID";
color: string;
opacity?: number;
}
/** Gradient fill */
export interface SimplifiedGradientFill {
type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND";
gradientHandlePositions?: Array<{ x: number; y: number }>;
gradientStops?: Array<{
position: number;
color: string;
}>;
}
/** Image fill */
export interface SimplifiedImageFill {
type: "IMAGE";
imageRef?: string;
scaleMode?: string;
}
/**
* Simplified node structure
* This is the main output type for the MCP response
*/
export interface SimplifiedNode {
/** Node ID */
id: string;
/** Node name */
name: string;
/** Node type (FRAME, TEXT, VECTOR, etc.) */
type: string;
/** Text content (for TEXT nodes) */
text?: string;
/** Legacy text style (for backward compatibility) */
style?: TextStyle;
/** CSS styles */
cssStyles?: CSSStyle;
/** Fill information */
fills?: SimplifiedFill[];
/** Export information (for image nodes) */
exportInfo?: ExportInfo;
/** Child nodes */
children?: SimplifiedNode[];
/** Internal: absolute X coordinate */
_absoluteX?: number;
/** Internal: absolute Y coordinate */
_absoluteY?: number;
}
/**
* Simplified design output
* Top-level structure returned by the MCP
*/
export interface SimplifiedDesign {
/** Design file name */
name: string;
/** Last modified timestamp */
lastModified: string;
/** File version */
version?: string;
/** Thumbnail URL */
thumbnailUrl: string;
/** Root nodes */
nodes: SimplifiedNode[];
}
// ==================== Algorithm Types ====================
/**
* Icon detection result
*/
export interface IconDetectionResult {
nodeId: string;
nodeName: string;
shouldMerge: boolean;
exportFormat: "SVG" | "PNG";
reason: string;
size?: { width: number; height: number };
childCount?: number;
}
/**
* Icon detection configuration
*/
export interface IconDetectionConfig {
/** Maximum icon size in pixels */
maxIconSize: number;
/** Minimum icon size in pixels */
minIconSize: number;
/** Minimum ratio of mergeable types */
mergeableRatio: number;
/** Maximum nesting depth */
maxDepth: number;
/** Maximum child count */
maxChildren: number;
/** Maximum size to respect exportSettings */
respectExportSettingsMaxSize: number;
}
/**
* Layout detection result
*/
export interface LayoutInfo {
type: "flex" | "absolute" | "grid";
direction?: "row" | "column";
gap?: number;
justifyContent?: string;
alignItems?: string;
/** Grid-specific: row gap */
rowGap?: number;
/** Grid-specific: column gap */
columnGap?: number;
/** Grid-specific: template columns (e.g., "100px 200px 100px") */
gridTemplateColumns?: string;
/** Grid-specific: template rows (e.g., "auto auto") */
gridTemplateRows?: string;
/** Grid confidence score (0-1) */
confidence?: number;
}
```
--------------------------------------------------------------------------------
/src/utils/css.ts:
--------------------------------------------------------------------------------
```typescript
/**
* CSS Utilities
*
* CSS output optimization and generation utilities.
* Used to reduce output size and improve readability.
*
* @module utils/css
*/
// ==================== CSS Shorthand Generation ====================
/**
* Generate CSS shorthand properties (such as padding, margin, border-radius)
*
* @example
* generateCSSShorthand({ top: 10, right: 10, bottom: 10, left: 10 }) // "10px"
* generateCSSShorthand({ top: 10, right: 20, bottom: 10, left: 20 }) // "10px 20px"
*/
export function generateCSSShorthand(
values: {
top: number;
right: number;
bottom: number;
left: number;
},
options: {
ignoreZero?: boolean;
suffix?: string;
} = {},
): string | undefined {
const { ignoreZero = true, suffix = "px" } = options;
const { top, right, bottom, left } = values;
if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) {
return undefined;
}
if (top === right && right === bottom && bottom === left) {
return `${top}${suffix}`;
}
if (right === left) {
if (top === bottom) {
return `${top}${suffix} ${right}${suffix}`;
}
return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`;
}
return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`;
}
// ==================== Numeric Precision Optimization ====================
/**
* Round a number to specified precision
* @param value Original number
* @param precision Number of decimal places, default 0 (integer)
*/
export function roundValue(value: number, precision: number = 0): number {
if (precision === 0) {
return Math.round(value);
}
const multiplier = Math.pow(10, precision);
return Math.round(value * multiplier) / multiplier;
}
/**
* Format px value, rounded to integer
* @param value Pixel value
*/
export function formatPxValue(value: number): string {
return `${Math.round(value)}px`;
}
/**
* Format numeric value, used for gap and other properties, rounded to integer
* @param value Numeric value
*/
export function formatNumericValue(value: number): string {
return `${Math.round(value)}px`;
}
// ==================== Browser Defaults ====================
/**
* Browser/Tailwind default values
* These values can be omitted from output
*/
export const BROWSER_DEFAULTS: Record<string, string | number | undefined> = {
// Text defaults
textAlign: "left",
verticalAlign: "top",
fontWeight: 400,
// Flex defaults
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "stretch",
// Position defaults (if all elements are absolute, can be omitted)
// position: 'static', // Not omitting for now, as we explicitly use absolute
// Other
opacity: "1",
borderStyle: "none",
};
/**
* Check if a value is the default value
*/
export function isDefaultValue(key: string, value: string | number | undefined): boolean {
if (value === undefined) return true;
const defaultValue = BROWSER_DEFAULTS[key];
if (defaultValue === undefined) return false;
// Handle number and string comparison
if (typeof defaultValue === "number" && typeof value === "number") {
return defaultValue === value;
}
return String(defaultValue) === String(value);
}
/**
* Omit default style values
* @param styles CSS style object
* @returns Optimized style object
*/
export function omitDefaultStyles<T extends Record<string, unknown>>(styles: T): Partial<T> {
const result: Partial<T> = {};
for (const [key, value] of Object.entries(styles)) {
// Skip undefined
if (value === undefined) continue;
// Skip default values
if (isDefaultValue(key, value as string | number)) continue;
// Keep non-default values
(result as Record<string, unknown>)[key] = value;
}
return result;
}
// ==================== Gap Analysis ====================
/**
* Analyze gap consistency
* @param gaps Array of gaps
* @param tolerancePercent Tolerance percentage, default 20%
*/
export function analyzeGapConsistency(
gaps: number[],
tolerancePercent: number = 20,
): {
isConsistent: boolean;
averageGap: number;
roundedGap: number;
variance: number;
} {
if (gaps.length === 0) {
return { isConsistent: true, averageGap: 0, roundedGap: 0, variance: 0 };
}
if (gaps.length === 1) {
const rounded = roundValue(gaps[0]);
return { isConsistent: true, averageGap: gaps[0], roundedGap: rounded, variance: 0 };
}
// Calculate average
const avg = gaps.reduce((a, b) => a + b, 0) / gaps.length;
// Calculate variance
const variance = gaps.reduce((sum, gap) => sum + Math.pow(gap - avg, 2), 0) / gaps.length;
const stdDev = Math.sqrt(variance);
// Determine consistency: standard deviation less than specified percentage of average
const tolerance = avg * (tolerancePercent / 100);
const isConsistent = stdDev <= tolerance;
// Round to integer
const roundedGap = roundValue(avg);
return { isConsistent, averageGap: avg, roundedGap, variance };
}
/**
* Round gap to common values
* Common values: 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64
*/
export function roundToCommonGap(gap: number): number {
const COMMON_GAPS = [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128];
// Find closest common value
let closest = COMMON_GAPS[0];
let minDiff = Math.abs(gap - closest);
for (const commonGap of COMMON_GAPS) {
const diff = Math.abs(gap - commonGap);
if (diff < minDiff) {
minDiff = diff;
closest = commonGap;
}
}
// If difference is too large (over 4px), use rounded value
if (minDiff > 4) {
return roundValue(gap);
}
return closest;
}
// ==================== Export Info Optimization ====================
/**
* Optimize exportInfo, omit nodeId if it's the same as node id
*/
export function optimizeExportInfo(
nodeId: string,
exportInfo: { type: string; format: string; nodeId?: string; fileName?: string },
): { type: string; format: string; nodeId?: string; fileName?: string } {
const result = { ...exportInfo };
// If nodeId is the same as node id, omit it
if (result.nodeId === nodeId) {
delete result.nodeId;
}
return result;
}
```
--------------------------------------------------------------------------------
/src/services/cache/lru-cache.ts:
--------------------------------------------------------------------------------
```typescript
/**
* LRU (Least Recently Used) Memory Cache
*
* A generic in-memory cache with LRU eviction policy.
* Used as a fast cache layer before disk cache.
*
* Features:
* - O(1) get/set operations
* - Automatic eviction of least recently used items
* - Optional TTL (time-to-live) per item
* - Size limiting by item count
* - Statistics tracking
*
* @module services/cache/lru-cache
*/
import type { NodeCacheEntry } from "./types.js";
export interface LRUCacheConfig {
/** Maximum number of items in cache */
maxSize: number;
/** Default TTL in milliseconds (0 = no expiration) */
defaultTTL: number;
}
interface CacheEntry<T> {
value: T;
createdAt: number;
expiresAt: number | null;
size?: number;
}
export interface CacheStats {
hits: number;
misses: number;
evictions: number;
size: number;
maxSize: number;
}
const DEFAULT_CONFIG: LRUCacheConfig = {
maxSize: 100,
defaultTTL: 0, // No expiration by default
};
/**
* Generic LRU Cache implementation using Map
* Map maintains insertion order, making it ideal for LRU
*/
export class LRUCache<T> {
private cache: Map<string, CacheEntry<T>>;
private config: LRUCacheConfig;
private stats: CacheStats;
constructor(config: Partial<LRUCacheConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.cache = new Map();
this.stats = {
hits: 0,
misses: 0,
evictions: 0,
size: 0,
maxSize: this.config.maxSize,
};
}
/**
* Get an item from cache
* Returns null if not found or expired
*/
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
this.stats.misses++;
return null;
}
// Check expiration
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.delete(key);
this.stats.misses++;
return null;
}
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
this.stats.hits++;
return entry.value;
}
/**
* Set an item in cache
* @param key Cache key
* @param value Value to cache
* @param ttl Optional TTL in milliseconds (overrides default)
*/
set(key: string, value: T, ttl?: number): void {
// If key exists, delete it first (to update position)
if (this.cache.has(key)) {
this.cache.delete(key);
} else {
// Evict if at capacity
while (this.cache.size >= this.config.maxSize) {
this.evictOldest();
}
}
const effectiveTTL = ttl ?? this.config.defaultTTL;
const entry: CacheEntry<T> = {
value,
createdAt: Date.now(),
expiresAt: effectiveTTL > 0 ? Date.now() + effectiveTTL : null,
};
this.cache.set(key, entry);
this.stats.size = this.cache.size;
}
/**
* Check if key exists (without updating access time)
*/
has(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
// Check expiration
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.delete(key);
return false;
}
return true;
}
/**
* Delete an item from cache
*/
delete(key: string): boolean {
const existed = this.cache.delete(key);
if (existed) {
this.stats.size = this.cache.size;
}
return existed;
}
/**
* Clear all items from cache
*/
clear(): void {
this.cache.clear();
this.stats.size = 0;
}
/**
* Get all keys in cache (most recent last)
*/
keys(): string[] {
return Array.from(this.cache.keys());
}
/**
* Get cache size
*/
get size(): number {
return this.cache.size;
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
return { ...this.stats };
}
/**
* Reset statistics
*/
resetStats(): void {
this.stats.hits = 0;
this.stats.misses = 0;
this.stats.evictions = 0;
}
/**
* Get hit rate (0-1)
*/
getHitRate(): number {
const total = this.stats.hits + this.stats.misses;
return total === 0 ? 0 : this.stats.hits / total;
}
/**
* Clean expired entries
*/
cleanExpired(): number {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of this.cache.entries()) {
if (entry.expiresAt && now > entry.expiresAt) {
this.cache.delete(key);
cleaned++;
}
}
this.stats.size = this.cache.size;
return cleaned;
}
/**
* Evict the oldest (least recently used) item
*/
private evictOldest(): void {
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
this.stats.evictions++;
this.stats.size = this.cache.size;
}
}
/**
* Peek at an item without updating access time
*/
peek(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
return null;
}
return entry.value;
}
/**
* Update config (applies to new entries only)
*/
updateConfig(config: Partial<LRUCacheConfig>): void {
this.config = { ...this.config, ...config };
this.stats.maxSize = this.config.maxSize;
// Evict if new maxSize is smaller
while (this.cache.size > this.config.maxSize) {
this.evictOldest();
}
}
}
/**
* Specialized LRU cache for Figma node data
* Includes version-aware caching
*/
export class NodeLRUCache extends LRUCache<NodeCacheEntry> {
/**
* Generate cache key for node data
*/
static generateKey(fileKey: string, nodeId?: string, depth?: number): string {
const parts = [fileKey];
if (nodeId) parts.push(`n:${nodeId}`);
if (depth !== undefined) parts.push(`d:${depth}`);
return parts.join(":");
}
/**
* Get node data with version check
*/
getNode(fileKey: string, nodeId?: string, depth?: number, version?: string): unknown | null {
const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
const entry = this.get(key);
if (!entry) return null;
// Version mismatch - cache is stale
if (version && entry.version && entry.version !== version) {
this.delete(key);
return null;
}
return entry.data;
}
/**
* Set node data with metadata
*/
setNode(
data: unknown,
fileKey: string,
nodeId?: string,
depth?: number,
version?: string,
ttl?: number,
): void {
const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
const entry: NodeCacheEntry = {
data,
fileKey,
nodeId,
version,
depth,
};
this.set(key, entry, ttl);
}
/**
* Invalidate all cache entries for a file
*/
invalidateFile(fileKey: string): number {
let invalidated = 0;
for (const key of this.keys()) {
if (key.startsWith(fileKey)) {
this.delete(key);
invalidated++;
}
}
return invalidated;
}
/**
* Invalidate cache entries for a specific node and its descendants
*/
invalidateNode(fileKey: string, nodeId: string): number {
let invalidated = 0;
const prefix = NodeLRUCache.generateKey(fileKey, nodeId);
for (const key of this.keys()) {
if (key.startsWith(prefix)) {
this.delete(key);
invalidated++;
}
}
return invalidated;
}
}
```
--------------------------------------------------------------------------------
/src/services/cache/cache-manager.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Unified Cache Manager
*
* Manages multi-layer caching with L1 (memory) and L2 (disk) layers.
*
* Cache hierarchy:
* - L1: In-memory LRU cache (fast, limited size)
* - L2: Disk-based cache (persistent, larger capacity)
*
* @module services/cache/cache-manager
*/
import type { CacheConfig, CacheStatistics } from "./types.js";
import { DEFAULT_MEMORY_CONFIG } from "./types.js";
import { NodeLRUCache } from "./lru-cache.js";
import { DiskCache } from "./disk-cache.js";
import os from "os";
import path from "path";
/**
* Default cache configuration
*/
const DEFAULT_CONFIG: CacheConfig = {
enabled: true,
memory: DEFAULT_MEMORY_CONFIG,
disk: {
cacheDir: path.join(os.homedir(), ".figma-mcp-cache"),
maxSize: 500 * 1024 * 1024, // 500MB
ttl: 24 * 60 * 60 * 1000, // 24 hours
},
};
/**
* Unified cache manager with multi-layer caching
*/
export class CacheManager {
private config: CacheConfig;
private memoryCache: NodeLRUCache;
private diskCache: DiskCache | null;
constructor(config: Partial<CacheConfig> = {}) {
this.config = this.mergeConfig(DEFAULT_CONFIG, config);
// Skip initialization if disabled
if (!this.config.enabled) {
this.memoryCache = new NodeLRUCache({ maxSize: 0, defaultTTL: 0 });
this.diskCache = null;
return;
}
// Initialize L1: Memory cache
this.memoryCache = new NodeLRUCache({
maxSize: this.config.memory.maxNodeItems,
defaultTTL: this.config.memory.nodeTTL,
});
// Initialize L2: Disk cache
this.diskCache = new DiskCache(this.config.disk);
}
/**
* Deep merge configuration
*/
private mergeConfig(defaults: CacheConfig, overrides: Partial<CacheConfig>): CacheConfig {
return {
enabled: overrides.enabled ?? defaults.enabled,
memory: {
...defaults.memory,
...overrides.memory,
},
disk: {
...defaults.disk,
...overrides.disk,
},
};
}
// ==================== Node Data Operations ====================
/**
* Get node data with multi-layer cache lookup
*
* Flow: L1 (memory) -> L2 (disk) -> null (cache miss)
*/
async getNodeData<T>(
fileKey: string,
nodeId?: string,
depth?: number,
version?: string,
): Promise<T | null> {
if (!this.config.enabled) return null;
// L1: Check memory cache
const memoryData = this.memoryCache.getNode(fileKey, nodeId, depth, version);
if (memoryData !== null) {
return memoryData as T;
}
// L2: Check disk cache
if (this.diskCache) {
const diskData = await this.diskCache.get<T>(fileKey, nodeId, depth, version);
if (diskData !== null) {
// Backfill L1 cache
this.memoryCache.setNode(diskData, fileKey, nodeId, depth, version);
return diskData;
}
}
return null;
}
/**
* Set node data in both cache layers
*/
async setNodeData<T>(
data: T,
fileKey: string,
nodeId?: string,
depth?: number,
version?: string,
): Promise<void> {
if (!this.config.enabled) return;
// Write to L1 (memory)
this.memoryCache.setNode(data, fileKey, nodeId, depth, version);
// Write to L2 (disk)
if (this.diskCache) {
await this.diskCache.set(data, fileKey, nodeId, depth, version);
}
}
/**
* Check if node data exists in cache
*/
async hasNodeData(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
if (!this.config.enabled) return false;
const key = NodeLRUCache.generateKey(fileKey, nodeId, depth);
if (this.memoryCache.has(key)) {
return true;
}
if (this.diskCache) {
return this.diskCache.has(fileKey, nodeId, depth);
}
return false;
}
// ==================== Image Operations ====================
/**
* Check if image is cached
*/
async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> {
if (!this.config.enabled || !this.diskCache) return null;
return this.diskCache.hasImage(fileKey, nodeId, format);
}
/**
* Cache image file
*/
async cacheImage(
sourcePath: string,
fileKey: string,
nodeId: string,
format: string,
): Promise<string> {
if (!this.config.enabled || !this.diskCache) return sourcePath;
return this.diskCache.cacheImage(sourcePath, fileKey, nodeId, format);
}
/**
* Copy image from cache to target path
*/
async copyImageFromCache(
fileKey: string,
nodeId: string,
format: string,
targetPath: string,
): Promise<boolean> {
if (!this.config.enabled || !this.diskCache) return false;
return this.diskCache.copyImageFromCache(fileKey, nodeId, format, targetPath);
}
// ==================== Invalidation Operations ====================
/**
* Invalidate all cache entries for a file
*/
async invalidateFile(fileKey: string): Promise<{ memory: number; disk: number }> {
const memoryInvalidated = this.memoryCache.invalidateFile(fileKey);
const diskInvalidated = this.diskCache ? await this.diskCache.invalidateFile(fileKey) : 0;
return { memory: memoryInvalidated, disk: diskInvalidated };
}
/**
* Invalidate cache for a specific node
*/
async invalidateNode(fileKey: string, nodeId: string): Promise<{ memory: number; disk: number }> {
const memoryInvalidated = this.memoryCache.invalidateNode(fileKey, nodeId);
const diskInvalidated = this.diskCache
? (await this.diskCache.delete(fileKey, nodeId))
? 1
: 0
: 0;
return { memory: memoryInvalidated, disk: diskInvalidated };
}
// ==================== Maintenance Operations ====================
/**
* Clean expired cache entries from all layers
*/
async cleanExpired(): Promise<{ memory: number; disk: number }> {
const memoryCleaned = this.memoryCache.cleanExpired();
const diskCleaned = this.diskCache ? await this.diskCache.cleanExpired() : 0;
return { memory: memoryCleaned, disk: diskCleaned };
}
/**
* Clear all cache
*/
async clearAll(): Promise<void> {
this.memoryCache.clear();
if (this.diskCache) {
await this.diskCache.clearAll();
}
}
/**
* Get combined cache statistics
*/
async getStats(): Promise<CacheStatistics> {
const memoryStats = this.memoryCache.getStats();
if (!this.diskCache) {
return {
enabled: this.config.enabled,
memory: {
hits: memoryStats.hits,
misses: memoryStats.misses,
size: memoryStats.size,
maxSize: memoryStats.maxSize,
hitRate: this.memoryCache.getHitRate(),
evictions: memoryStats.evictions,
},
disk: {
hits: 0,
misses: 0,
totalSize: 0,
maxSize: this.config.disk.maxSize,
nodeFileCount: 0,
imageFileCount: 0,
},
};
}
const diskStats = await this.diskCache.getStats();
return {
enabled: this.config.enabled,
memory: {
hits: memoryStats.hits,
misses: memoryStats.misses,
size: memoryStats.size,
maxSize: memoryStats.maxSize,
hitRate: this.memoryCache.getHitRate(),
evictions: memoryStats.evictions,
},
disk: diskStats,
};
}
/**
* Get cache directory path
*/
getCacheDir(): string {
return this.config.disk.cacheDir;
}
/**
* Check if caching is enabled
*/
isEnabled(): boolean {
return this.config.enabled;
}
/**
* Reset statistics
*/
resetStats(): void {
this.memoryCache.resetStats();
}
}
// Export singleton instance
export const cacheManager = new CacheManager();
```
--------------------------------------------------------------------------------
/src/core/layout.ts:
--------------------------------------------------------------------------------
```typescript
import { isFrame, isLayout, isRectangle } from "~/utils/validation.js";
import type {
Node as FigmaDocumentNode,
HasFramePropertiesTrait,
HasLayoutTrait,
} from "@figma/rest-api-spec";
import { generateCSSShorthand } from "~/utils/css.js";
export interface SimplifiedLayout {
mode: "none" | "row" | "column";
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
alignSelf?: "flex-start" | "flex-end" | "center" | "stretch";
wrap?: boolean;
gap?: string;
locationRelativeToParent?: {
x: number;
y: number;
};
dimensions?: {
width?: number;
height?: number;
aspectRatio?: number;
};
padding?: string;
sizing?: {
horizontal?: "fixed" | "fill" | "hug";
vertical?: "fixed" | "fill" | "hug";
};
overflowScroll?: ("x" | "y")[];
position?: "absolute";
}
// Convert Figma's layout config into a more typical flex-like schema
export function buildSimplifiedLayout(
n: FigmaDocumentNode,
parent?: FigmaDocumentNode,
): SimplifiedLayout {
const frameValues = buildSimplifiedFrameValues(n);
const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {};
return { ...frameValues, ...layoutValues };
}
/**
* Convert Figma's primaryAxisAlignItems to CSS justifyContent
* Primary axis: horizontal for row, vertical for column
*/
function convertJustifyContent(
axisAlign?: HasFramePropertiesTrait["primaryAxisAlignItems"],
stretch?: {
children: FigmaDocumentNode[];
mode: "row" | "column";
},
) {
// Check if all children fill the main axis (stretch behavior)
if (stretch) {
const { children, mode } = stretch;
const shouldStretch =
children.length > 0 &&
children.every((c) => {
if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
// Primary axis: horizontal for row, vertical for column
if (mode === "row") {
return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
} else {
return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
}
});
if (shouldStretch) return "stretch";
}
switch (axisAlign) {
case "MIN":
// MIN, AKA flex-start, is the default alignment
return undefined;
case "MAX":
return "flex-end";
case "CENTER":
return "center";
case "SPACE_BETWEEN":
return "space-between";
default:
return undefined;
}
}
/**
* Convert Figma's counterAxisAlignItems to CSS alignItems
* Counter axis: vertical for row, horizontal for column
* Note: SPACE_BETWEEN is not valid for counter axis in Figma
*/
function convertAlignItems(
axisAlign?: HasFramePropertiesTrait["counterAxisAlignItems"],
stretch?: {
children: FigmaDocumentNode[];
mode: "row" | "column";
},
) {
// Check if all children fill the cross axis (stretch behavior)
if (stretch) {
const { children, mode } = stretch;
const shouldStretch =
children.length > 0 &&
children.every((c) => {
if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
// Counter axis: vertical for row, horizontal for column
if (mode === "row") {
return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
} else {
return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
}
});
if (shouldStretch) return "stretch";
}
switch (axisAlign) {
case "MIN":
// MIN, AKA flex-start, is the default alignment
return undefined;
case "MAX":
return "flex-end";
case "CENTER":
return "center";
case "BASELINE":
return "baseline";
default:
return undefined;
}
}
function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) {
switch (align) {
case "MIN":
// MIN, AKA flex-start, is the default alignment
return undefined;
case "MAX":
return "flex-end";
case "CENTER":
return "center";
case "STRETCH":
return "stretch";
default:
return undefined;
}
}
// interpret sizing
function convertSizing(
s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"],
) {
if (s === "FIXED") return "fixed";
if (s === "FILL") return "fill";
if (s === "HUG") return "hug";
return undefined;
}
function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } {
if (!isFrame(n)) {
return { mode: "none" };
}
const frameValues: SimplifiedLayout = {
mode:
!n.layoutMode || n.layoutMode === "NONE"
? "none"
: n.layoutMode === "HORIZONTAL"
? "row"
: "column",
};
const overflowScroll: SimplifiedLayout["overflowScroll"] = [];
if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x");
if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y");
if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll;
if (frameValues.mode === "none") {
return frameValues;
}
// Convert Figma alignment to CSS flex properties
frameValues.justifyContent = convertJustifyContent(n.primaryAxisAlignItems ?? "MIN", {
children: n.children,
mode: frameValues.mode,
});
frameValues.alignItems = convertAlignItems(n.counterAxisAlignItems ?? "MIN", {
children: n.children,
mode: frameValues.mode,
});
frameValues.alignSelf = convertSelfAlign(n.layoutAlign);
// Only include wrap if it's set to WRAP, since flex layouts don't default to wrapping
frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined;
frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : undefined;
// gather padding
if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) {
frameValues.padding = generateCSSShorthand({
top: n.paddingTop ?? 0,
right: n.paddingRight ?? 0,
bottom: n.paddingBottom ?? 0,
left: n.paddingLeft ?? 0,
});
}
return frameValues;
}
function buildSimplifiedLayoutValues(
n: FigmaDocumentNode,
parent: FigmaDocumentNode | undefined,
mode: "row" | "column" | "none",
): SimplifiedLayout | undefined {
if (!isLayout(n)) return undefined;
const layoutValues: SimplifiedLayout = { mode };
layoutValues.sizing = {
horizontal: convertSizing(n.layoutSizingHorizontal),
vertical: convertSizing(n.layoutSizingVertical),
};
// Only include positioning-related properties if parent layout isn't flex or if the node is absolute
if (isFrame(parent) && (parent?.layoutMode === "NONE" || n.layoutPositioning === "ABSOLUTE")) {
if (n.layoutPositioning === "ABSOLUTE") {
layoutValues.position = "absolute";
}
if (n.absoluteBoundingBox && parent.absoluteBoundingBox) {
layoutValues.locationRelativeToParent = {
x: n.absoluteBoundingBox.x - (parent?.absoluteBoundingBox?.x ?? n.absoluteBoundingBox.x),
y: n.absoluteBoundingBox.y - (parent?.absoluteBoundingBox?.y ?? n.absoluteBoundingBox.y),
};
}
return layoutValues;
}
// Handle dimensions based on layout growth and alignment
if (isRectangle("absoluteBoundingBox", n) && isRectangle("absoluteBoundingBox", parent)) {
const dimensions: { width?: number; height?: number; aspectRatio?: number } = {};
// Only include dimensions that aren't meant to stretch
if (mode === "row") {
if (!n.layoutGrow && n.layoutSizingHorizontal === "FIXED")
dimensions.width = n.absoluteBoundingBox.width;
if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical === "FIXED")
dimensions.height = n.absoluteBoundingBox.height;
} else if (mode === "column") {
// column
if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal === "FIXED")
dimensions.width = n.absoluteBoundingBox.width;
if (!n.layoutGrow && n.layoutSizingVertical === "FIXED")
dimensions.height = n.absoluteBoundingBox.height;
if (n.preserveRatio) {
dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height;
}
}
if (Object.keys(dimensions).length > 0) {
layoutValues.dimensions = dimensions;
}
}
return layoutValues;
}
```
--------------------------------------------------------------------------------
/tests/unit/algorithms/icon.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Icon Detection Algorithm Unit Tests
*
* Tests the icon detection algorithm for identifying and merging
* vector layers that should be exported as single icons.
*/
import { describe, it, expect, beforeAll } from "vitest";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import {
detectIcon,
analyzeNodeTree,
DEFAULT_CONFIG,
type FigmaNode,
type IconDetectionResult,
} from "~/algorithms/icon/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesPath = path.join(__dirname, "../../fixtures");
// Load test fixture
function loadTestData(): FigmaNode {
const dataPath = path.join(fixturesPath, "figma-data/real-node-data.json");
const rawData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
const nodeKey = Object.keys(rawData.nodes)[0];
return rawData.nodes[nodeKey].document;
}
// Count total icons detected
function countIcons(results: IconDetectionResult[]): number {
return results.filter((r) => r.shouldMerge).length;
}
describe("Icon Detection Algorithm", () => {
let testData: FigmaNode;
beforeAll(() => {
testData = loadTestData();
});
describe("Configuration", () => {
it("should have sensible default configuration", () => {
expect(DEFAULT_CONFIG.maxIconSize).toBe(300);
expect(DEFAULT_CONFIG.minIconSize).toBe(8);
expect(DEFAULT_CONFIG.mergeableRatio).toBe(0.6);
expect(DEFAULT_CONFIG.maxDepth).toBe(5);
});
});
describe("Size Constraints", () => {
it("should reject nodes that are too large", () => {
const largeNode: FigmaNode = {
id: "large-1",
name: "Large Node",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 500, height: 500 },
children: [{ id: "child-1", name: "Vector", type: "VECTOR" }],
};
const result = detectIcon(largeNode, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(false);
expect(result.reason).toContain("large");
});
it("should reject nodes that are too small", () => {
const smallNode: FigmaNode = {
id: "small-1",
name: "Small Node",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 4, height: 4 },
children: [{ id: "child-1", name: "Vector", type: "VECTOR" }],
};
const result = detectIcon(smallNode, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(false);
});
});
describe("Node Type Detection", () => {
it("should detect vector-only groups as icons", () => {
const vectorGroup: FigmaNode = {
id: "icon-1",
name: "Search Icon",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
children: [
{ id: "v1", name: "Circle", type: "ELLIPSE" },
{ id: "v2", name: "Line", type: "LINE" },
],
};
const result = detectIcon(vectorGroup, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(true);
expect(result.exportFormat).toBe("SVG");
});
it("should reject groups containing TEXT nodes", () => {
const textGroup: FigmaNode = {
id: "text-group",
name: "Button with Text",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 40 },
children: [
{ id: "bg", name: "Background", type: "RECTANGLE" },
{ id: "label", name: "Label", type: "TEXT" },
],
};
const result = detectIcon(textGroup, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(false);
expect(result.reason).toContain("TEXT");
});
});
describe("Export Format Selection", () => {
it("should choose SVG for pure vector icons", () => {
const vectorIcon: FigmaNode = {
id: "svg-icon",
name: "Star",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
children: [{ id: "star", name: "Star", type: "STAR" }],
};
const result = detectIcon(vectorIcon, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(true);
expect(result.exportFormat).toBe("SVG");
});
it("should choose PNG for icons with complex effects", () => {
const effectIcon: FigmaNode = {
id: "effect-icon",
name: "Shadow Icon",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
effects: [{ type: "DROP_SHADOW", visible: true }],
children: [{ id: "shape", name: "Shape", type: "RECTANGLE" }],
};
const result = detectIcon(effectIcon, DEFAULT_CONFIG);
if (result.shouldMerge) {
expect(result.exportFormat).toBe("PNG");
}
});
it("should respect designer-specified export settings", () => {
const exportNode: FigmaNode = {
id: "export-icon",
name: "Custom Export",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 32, height: 32 },
exportSettings: [{ format: "PNG", suffix: "", constraint: { type: "SCALE", value: 2 } }],
children: [{ id: "v1", name: "Vector", type: "VECTOR" }],
};
const result = detectIcon(exportNode, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(true);
expect(result.exportFormat).toBe("PNG");
});
});
describe("Mergeable Types", () => {
const mergeableTypes = [
"VECTOR",
"ELLIPSE",
"RECTANGLE",
"STAR",
"POLYGON",
"LINE",
"BOOLEAN_OPERATION",
];
mergeableTypes.forEach((type) => {
it(`should recognize ${type} as mergeable`, () => {
const node: FigmaNode = {
id: `${type.toLowerCase()}-icon`,
name: `${type} Icon`,
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
children: [{ id: "child", name: type, type: type }],
};
const result = detectIcon(node, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(true);
});
});
});
describe("Real Figma Data", () => {
it("should load and parse real Figma data", () => {
expect(testData).toBeDefined();
expect(testData.type).toBe("GROUP");
});
it("should analyze entire node tree", () => {
const result = analyzeNodeTree(testData, DEFAULT_CONFIG);
expect(result).toHaveProperty("processedTree");
expect(result).toHaveProperty("exportableIcons");
expect(result).toHaveProperty("summary");
expect(Array.isArray(result.exportableIcons)).toBe(true);
});
it("should detect appropriate number of icons", () => {
const result = analyzeNodeTree(testData, DEFAULT_CONFIG);
const iconCount = countIcons(result.exportableIcons);
// Should detect some icons but not too many (avoid fragmentation)
expect(iconCount).toBeGreaterThanOrEqual(0);
expect(iconCount).toBeLessThan(10); // Should be merged, not fragmented
});
it("should not mark root node as icon", () => {
const result = detectIcon(testData, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(false);
});
});
describe("Edge Cases", () => {
it("should handle nodes without children", () => {
const leafNode: FigmaNode = {
id: "leaf",
name: "Single Vector",
type: "VECTOR",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
};
const result = detectIcon(leafNode, DEFAULT_CONFIG);
expect(result).toBeDefined();
});
it("should handle nodes without bounding box", () => {
const noBoundsNode: FigmaNode = {
id: "no-bounds",
name: "No Bounds",
type: "GROUP",
children: [],
};
const result = detectIcon(noBoundsNode, DEFAULT_CONFIG);
expect(result.shouldMerge).toBe(false);
});
it("should handle deeply nested structures", () => {
const deepNode: FigmaNode = {
id: "deep",
name: "Deep",
type: "GROUP",
absoluteBoundingBox: { x: 0, y: 0, width: 24, height: 24 },
children: [
{
id: "level1",
name: "Level 1",
type: "GROUP",
children: [
{
id: "level2",
name: "Level 2",
type: "GROUP",
children: [{ id: "vector", name: "Vector", type: "VECTOR" }],
},
],
},
],
};
const result = detectIcon(deepNode, DEFAULT_CONFIG);
expect(result).toBeDefined();
});
});
});
```
--------------------------------------------------------------------------------
/tests/integration/parser.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Parser Integration Tests
*
* Tests the complete parsing pipeline from raw Figma API response
* to simplified node structure.
*/
import { describe, it, expect, beforeAll } from "vitest";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { parseFigmaResponse } from "~/core/parser.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesPath = path.join(__dirname, "../fixtures");
// Load fixtures
function loadRawData(): unknown {
const dataPath = path.join(fixturesPath, "figma-data/real-node-data.json");
return JSON.parse(fs.readFileSync(dataPath, "utf-8"));
}
function loadExpectedOutput(): unknown {
const dataPath = path.join(fixturesPath, "expected/real-node-data-optimized.json");
return JSON.parse(fs.readFileSync(dataPath, "utf-8"));
}
describe("Figma Response Parser", () => {
let rawData: unknown;
let expectedOutput: ReturnType<typeof loadExpectedOutput>;
beforeAll(() => {
rawData = loadRawData();
expectedOutput = loadExpectedOutput();
});
describe("Basic Parsing", () => {
it("should parse raw Figma response", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
expect(result).toBeDefined();
expect(result.name).toBeDefined();
expect(result.nodes).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
});
it("should extract file metadata", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
expect(result.name).toBe("Vigilkids产品站");
expect(result.lastModified).toBeDefined();
});
});
describe("Node Structure", () => {
it("should preserve node hierarchy", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
expect(result.nodes.length).toBeGreaterThan(0);
const rootNode = result.nodes[0];
expect(rootNode.id).toBeDefined();
expect(rootNode.name).toBeDefined();
expect(rootNode.type).toBeDefined();
});
it("should generate CSS styles", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
const rootNode = result.nodes[0];
expect(rootNode.cssStyles).toBeDefined();
expect(rootNode.cssStyles?.width).toBeDefined();
expect(rootNode.cssStyles?.height).toBeDefined();
});
});
describe("Data Compression", () => {
it("should significantly reduce data size", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
const originalSize = Buffer.byteLength(JSON.stringify(rawData));
const simplifiedSize = Buffer.byteLength(JSON.stringify(result));
const compressionRate = ((originalSize - simplifiedSize) / originalSize) * 100;
// Should achieve at least 70% compression
expect(compressionRate).toBeGreaterThan(70);
});
});
describe("CSS Style Generation", () => {
it("should convert colors to hex format", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
// Find a node with background color
const findNodeWithBg = (
nodes: Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
): Record<string, unknown> | null => {
for (const node of nodes) {
if (node.cssStyles?.backgroundColor) {
return node.cssStyles;
}
if (node.children) {
const found = findNodeWithBg(
node.children as Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
);
if (found) return found;
}
}
return null;
};
const styles = findNodeWithBg(result.nodes);
if (styles?.backgroundColor) {
expect(styles.backgroundColor).toMatch(/^#[A-Fa-f0-9]{6}$/);
}
});
it("should round pixel values to integers", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
const rootNode = result.nodes[0];
const width = rootNode.cssStyles?.width as string;
const height = rootNode.cssStyles?.height as string;
// Should be integer pixel values
expect(width).toMatch(/^\d+px$/);
expect(height).toMatch(/^\d+px$/);
});
});
describe("Layout Detection Integration", () => {
it("should detect flex layouts in appropriate nodes", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
// Find nodes with flex properties
const findFlexNode = (
nodes: Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
): Record<string, unknown> | null => {
for (const node of nodes) {
if (node.cssStyles?.display === "flex") {
return node.cssStyles;
}
if (node.children) {
const found = findFlexNode(
node.children as Array<{ cssStyles?: Record<string, unknown>; children?: unknown[] }>,
);
if (found) return found;
}
}
return null;
};
const flexStyles = findFlexNode(result.nodes);
if (flexStyles) {
expect(flexStyles.display).toBe("flex");
expect(flexStyles.flexDirection).toBeDefined();
}
});
});
describe("Icon Detection Integration", () => {
it("should mark icon nodes with exportInfo", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
// Find nodes with export info
const findExportNode = (
nodes: Array<{ exportInfo?: unknown; children?: unknown[] }>,
): unknown | null => {
for (const node of nodes) {
if (node.exportInfo) {
return node.exportInfo;
}
if (node.children) {
const found = findExportNode(
node.children as Array<{ exportInfo?: unknown; children?: unknown[] }>,
);
if (found) return found;
}
}
return null;
};
const exportInfo = findExportNode(result.nodes);
if (exportInfo) {
expect(exportInfo).toHaveProperty("type");
expect(exportInfo).toHaveProperty("format");
expect(exportInfo).toHaveProperty("fileName");
}
});
});
describe("Text Node Processing", () => {
it("should extract text content", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
// Find text nodes
const findTextNode = (
nodes: Array<{ type?: string; text?: string; children?: unknown[] }>,
): { text?: string } | null => {
for (const node of nodes) {
if (node.type === "TEXT" && node.text) {
return node;
}
if (node.children) {
const found = findTextNode(
node.children as Array<{ type?: string; text?: string; children?: unknown[] }>,
);
if (found) return found;
}
}
return null;
};
const textNode = findTextNode(result.nodes);
if (textNode) {
expect(textNode.text).toBeDefined();
expect(typeof textNode.text).toBe("string");
}
});
it("should include font styles for text nodes", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
const findTextStyles = (
nodes: Array<{ type?: string; cssStyles?: Record<string, unknown>; children?: unknown[] }>,
): Record<string, unknown> | null => {
for (const node of nodes) {
if (node.type === "TEXT" && node.cssStyles) {
return node.cssStyles;
}
if (node.children) {
const found = findTextStyles(
node.children as Array<{
type?: string;
cssStyles?: Record<string, unknown>;
children?: unknown[];
}>,
);
if (found) return found;
}
}
return null;
};
const textStyles = findTextStyles(result.nodes);
if (textStyles) {
expect(textStyles.fontFamily).toBeDefined();
expect(textStyles.fontSize).toBeDefined();
}
});
});
describe("Output Stability", () => {
it("should produce consistent output structure", () => {
const result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
// Compare key structure with expected output
expect(Object.keys(result)).toEqual(Object.keys(expectedOutput as object));
expect(result.nodes.length).toBe((expectedOutput as { nodes: unknown[] }).nodes.length);
});
});
});
```
--------------------------------------------------------------------------------
/src/resources/figma-resources.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Figma Resources - Expose Figma data as MCP Resources
* Resources are lightweight, on-demand data sources that save tokens
*/
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { FigmaService } from "../services/figma.js";
import type { SimplifiedNode } from "../types/index.js";
// ==================== Types ====================
export interface FileMetadata {
name: string;
lastModified: string;
version: string;
pages: Array<{ id: string; name: string; childCount: number }>;
thumbnailUrl?: string;
}
export interface StyleTokens {
colors: Array<{ name: string; value: string; hex: string }>;
typography: Array<{
name: string;
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight?: number;
}>;
effects: Array<{ name: string; type: string; value: string }>;
}
export interface ComponentSummary {
id: string;
name: string;
description?: string;
type: "COMPONENT" | "COMPONENT_SET";
variants?: string[];
}
// ==================== Resource Handlers ====================
/**
* Extract file metadata (lightweight, ~200 tokens)
*/
export async function getFileMetadata(
figmaService: FigmaService,
fileKey: string,
): Promise<FileMetadata> {
const file = await figmaService.getFile(fileKey, 1); // depth=1 for minimal data
const pages = file.nodes
.filter((node) => node.type === "CANVAS")
.map((page) => ({
id: page.id,
name: page.name,
childCount: page.children?.length ?? 0,
}));
return {
name: file.name,
lastModified: file.lastModified,
version: file.version ?? "",
pages,
};
}
/**
* Extract style tokens from file (colors, typography, effects) (~500 tokens)
*/
export async function getStyleTokens(
figmaService: FigmaService,
fileKey: string,
): Promise<StyleTokens> {
const file = await figmaService.getFile(fileKey, 3); // Need some depth for styles
const colors: StyleTokens["colors"] = [];
const typography: StyleTokens["typography"] = [];
const effects: StyleTokens["effects"] = [];
const seenColors = new Set<string>();
const seenFonts = new Set<string>();
function extractFromNode(node: SimplifiedNode) {
// Extract colors from fills
if (node.cssStyles) {
const bgColor = node.cssStyles.background || node.cssStyles.backgroundColor;
if (bgColor && !seenColors.has(bgColor)) {
seenColors.add(bgColor);
colors.push({
name: node.name || "unnamed",
value: bgColor,
hex: bgColor,
});
}
const textColor = node.cssStyles.color;
if (textColor && !seenColors.has(textColor)) {
seenColors.add(textColor);
colors.push({
name: `${node.name || "text"}-color`,
value: textColor,
hex: textColor,
});
}
// Extract typography
if (node.cssStyles.fontFamily && node.cssStyles.fontSize) {
const fontKey = `${node.cssStyles.fontFamily}-${node.cssStyles.fontSize}-${node.cssStyles.fontWeight || 400}`;
if (!seenFonts.has(fontKey)) {
seenFonts.add(fontKey);
typography.push({
name: node.name || "text",
fontFamily: node.cssStyles.fontFamily,
fontSize: parseFloat(String(node.cssStyles.fontSize)) || 14,
fontWeight: parseFloat(String(node.cssStyles.fontWeight)) || 400,
lineHeight: node.cssStyles.lineHeight
? parseFloat(String(node.cssStyles.lineHeight))
: undefined,
});
}
}
// Extract effects (shadows, blur)
if (node.cssStyles.boxShadow) {
effects.push({
name: `${node.name || "element"}-shadow`,
type: "shadow",
value: String(node.cssStyles.boxShadow),
});
}
}
// Recurse into children
if (node.children) {
for (const child of node.children) {
extractFromNode(child);
}
}
}
for (const node of file.nodes) {
extractFromNode(node);
}
// Limit results to avoid token bloat
return {
colors: colors.slice(0, 20),
typography: typography.slice(0, 10),
effects: effects.slice(0, 10),
};
}
/**
* Extract component list (~300 tokens)
*/
export async function getComponentList(
figmaService: FigmaService,
fileKey: string,
): Promise<ComponentSummary[]> {
const file = await figmaService.getFile(fileKey, 5); // Need depth for components
const components: ComponentSummary[] = [];
function findComponents(node: SimplifiedNode) {
if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
components.push({
id: node.id,
name: node.name,
type: node.type as "COMPONENT" | "COMPONENT_SET",
variants:
node.type === "COMPONENT_SET" ? node.children?.map((c) => c.name).slice(0, 5) : undefined,
});
}
if (node.children) {
for (const child of node.children) {
findComponents(child);
}
}
}
for (const node of file.nodes) {
findComponents(node);
}
return components.slice(0, 50); // Limit to 50 components
}
/**
* Extract images/assets list (~400 tokens)
*/
export async function getAssetList(
figmaService: FigmaService,
fileKey: string,
): Promise<
Array<{
nodeId: string;
name: string;
type: "vector" | "image" | "icon";
exportFormats: string[];
imageRef?: string;
}>
> {
const file = await figmaService.getFile(fileKey, 5);
const assets: Array<{
nodeId: string;
name: string;
type: "vector" | "image" | "icon";
exportFormats: string[];
imageRef?: string;
}> = [];
function findAssets(node: SimplifiedNode) {
// Check for exportable assets (exportInfo is a single object, not array)
if (node.exportInfo) {
const isIcon =
node.type === "VECTOR" ||
node.type === "BOOLEAN_OPERATION" ||
node.exportInfo.type === "IMAGE";
assets.push({
nodeId: node.id,
name: node.name,
type: isIcon ? "icon" : "vector",
exportFormats: [node.exportInfo.format],
});
}
// Check for image fills in fills array
const imageFill = node.fills?.find(
(fill): fill is { type: "IMAGE"; imageRef?: string } =>
typeof fill === "object" && "type" in fill && fill.type === "IMAGE",
);
if (imageFill?.imageRef) {
assets.push({
nodeId: node.id,
name: node.name,
type: "image",
exportFormats: ["png", "jpg"],
imageRef: imageFill.imageRef,
});
}
if (node.children) {
for (const child of node.children) {
findAssets(child);
}
}
}
for (const node of file.nodes) {
findAssets(node);
}
return assets.slice(0, 100); // Limit to 100 assets
}
// ==================== Resource Templates ====================
/**
* Create resource template for file metadata
*/
export function createFileMetadataTemplate(): ResourceTemplate {
return new ResourceTemplate("figma://file/{fileKey}", {
list: undefined, // Can't list all files without user's file list
complete: {
fileKey: async () => [], // Could be enhanced with recent files
},
});
}
/**
* Create resource template for style tokens
*/
export function createStylesTemplate(): ResourceTemplate {
return new ResourceTemplate("figma://file/{fileKey}/styles", {
list: undefined,
complete: {
fileKey: async () => [],
},
});
}
/**
* Create resource template for components
*/
export function createComponentsTemplate(): ResourceTemplate {
return new ResourceTemplate("figma://file/{fileKey}/components", {
list: undefined,
complete: {
fileKey: async () => [],
},
});
}
/**
* Create resource template for assets
*/
export function createAssetsTemplate(): ResourceTemplate {
return new ResourceTemplate("figma://file/{fileKey}/assets", {
list: undefined,
complete: {
fileKey: async () => [],
},
});
}
// ==================== Help Content ====================
export const FIGMA_MCP_HELP = `# Figma MCP Server - Resource Guide
## Available Resources
### File Metadata
\`figma://file/{fileKey}\`
Returns: File name, pages, last modified date
Token cost: ~200
### Design Tokens (Styles)
\`figma://file/{fileKey}/styles\`
Returns: Colors, typography, effects extracted from file
Token cost: ~500
### Component List
\`figma://file/{fileKey}/components\`
Returns: All components and component sets with variants
Token cost: ~300
### Asset List
\`figma://file/{fileKey}/assets\`
Returns: Exportable images, icons, vectors with node IDs
Token cost: ~400
## How to Get fileKey
From Figma URL: \`figma.com/file/{fileKey}/...\`
Or: \`figma.com/design/{fileKey}/...\`
## Example Usage
1. Read file metadata: \`figma://file/abc123\`
2. Get color palette: \`figma://file/abc123/styles\`
3. List components: \`figma://file/abc123/components\`
4. Find assets to download: \`figma://file/abc123/assets\`
## Tools vs Resources
- **Resources**: Read-only data, user-controlled, lightweight
- **Tools**: Actions (download images), AI-controlled, heavier
Use Resources for exploration, Tools for execution.
`;
```
--------------------------------------------------------------------------------
/docs/en/absolute-to-relative-research.md:
--------------------------------------------------------------------------------
```markdown
# Absolute Position to Margin/Padding Conversion Research
## Research Overview
This document summarizes industry implementations and academic research on converting absolute positioning (`position: absolute` + `left/top`) to relative layouts (`margin`, `padding`, `gap`).
## 1. Industry Implementations
### 1.1 FigmaToCode (bernaferrari/FigmaToCode)
**Approach**: AltNodes Intermediate Representation
**Key Points**:
- Uses a 4-stage transformation pipeline:
1. Node Conversion - Figma nodes → JSON with optimizations
2. Intermediate Representation - JSON → AltNodes (virtual DOM)
3. Layout Optimization - Detect auto-layouts, responsive constraints
4. Code Generation - Framework-specific output
- For complex layouts (absolute + auto-layout), makes "intelligent decisions about structure"
- Detects parent-child relationships and z-index ordering
- Uses `insets` for best cases, `left/top` for worst cases
**Source**: https://github.com/bernaferrari/FigmaToCode
### 1.2 Facebook Yoga Layout Engine
**Approach**: CSS Flexbox Implementation in C++
**Padding/Margin Calculation**:
```
paddingAndBorderForAxis = leadingPaddingAndBorder + trailingPaddingAndBorder
marginForAxis = leadingMargin + trailingMargin
```
**Resolution Algorithm**:
- UnitPoint: Direct pixel value
- UnitPercent: `value * parentSize / 100`
- UnitAuto: Returns 0 for margins
**Key Functions**:
- `nodeLeadingPadding()` - Leading edge padding
- `nodeTrailingPadding()` - Trailing edge padding
- `nodeMarginForAxis()` - Total margin for axis
- `resolveValue()` - Unit conversion
**Source**: https://github.com/facebook/yoga
### 1.3 imgcook (Alibaba)
**Approach**: Rule-based + CV-based Layout Algorithm
**Key Points**:
- Converts design layers to flat JSON with absolute positions
- Uses rule-based algorithms to merge adjacent rows/blocks
- CV-based approach for generalization (pixel-level comparison)
- No specific formulas disclosed for margin/padding calculation
**Limitation**: Implementation details not publicly available
**Source**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
### 1.4 teleportHQ UIDL
**Approach**: Abstract UI Description Language
**Key Points**:
- CSS-like style properties in UIDL
- Design tokens for spacing constants (→ CSS variables)
- Does not appear to handle coordinate-based conversion
**Source**: https://github.com/teleporthq/teleport-code-generators
## 2. Academic Research
### 2.1 Layout Inference Algorithm for GUIs
**Paper**: "A layout inference algorithm for Graphical User Interfaces" (2015)
**Approach**: Two-phase algorithm using Allen's Interval Algebra
**Phase 1: Coordinate → Relative Positioning**
- Change coordinate-based positioning to relative positioning
- Use directed graphs and Allen relations
- Build spatial relationships between elements
**Phase 2: Pattern Matching & Graph Rewriting**
- Apply exploratory algorithm
- Pattern matching to identify layout structures
- Graph rewriting to obtain layout solutions
**Results**:
- 97% faithful to original views
- 84% maintain proportions when resized
**Source**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
### 2.2 Allen's Interval Algebra
**13 Basic Relations** (applicable to spatial layout):
| Relation | Symbol | Description |
| ------------- | ------ | ----------------------------------------------- |
| Precedes | p | A ends before B starts (gap between) |
| Meets | m | A ends exactly where B starts |
| Overlaps | o | A starts before B, they overlap, B ends after A |
| Finished-by | F | B starts during A, ends together |
| Contains | D | A completely contains B |
| Starts | s | A and B start together, A ends first |
| Equals | e | A and B identical |
| Started-by | S | B starts when A starts, B ends after |
| During | d | B completely contains A |
| Finishes | f | A starts during B, ends together |
| Overlapped-by | O | B starts before A, they overlap |
| Met-by | M | A starts exactly where B ends |
| Preceded-by | P | B ends before A starts (gap between) |
**Application**: Determine spatial relationships to infer layout structure
**Source**: https://ics.uci.edu/~alspaugh/cls/shr/allen.html
## 3. Key Insights
### 3.1 Padding Inference Formula
When a parent container has children, padding can be inferred:
```
paddingTop = firstChild.y - parent.y
paddingLeft = firstChild.x - parent.x
paddingBottom = (parent.y + parent.height) - (lastChild.y + lastChild.height)
paddingRight = (parent.x + parent.width) - (lastChild.x + lastChild.width)
```
**For Row Layout** (flex-direction: row):
- Sort children by X position
- `paddingLeft` = first child's left offset from parent
- `paddingTop` = minimum top offset among children
- `gap` = consistent spacing between children (already implemented)
**For Column Layout** (flex-direction: column):
- Sort children by Y position
- `paddingTop` = first child's top offset from parent
- `paddingLeft` = minimum left offset among children
- `gap` = consistent spacing between children (already implemented)
### 3.2 Individual Margin Calculation
For elements that don't align perfectly with the primary axis:
```
For Row Layout:
expectedY = parent.y + paddingTop
marginTop = child.y - expectedY
For Column Layout:
expectedX = parent.x + paddingLeft
marginLeft = child.x - expectedX
```
### 3.3 Cross-Axis Alignment vs Margin
When elements have different cross-axis positions:
**Option A: Use align-items + individual margins**
```css
.parent {
display: flex;
align-items: flex-start;
}
.child-offset {
margin-top: 10px; /* Individual offset */
}
```
**Option B: Use align-items: center/stretch**
If all elements are centered or stretched, no individual margins needed.
### 3.4 Absolute Position Preservation
Some elements MUST keep absolute positioning:
- Overlapping elements (IoU > 0.1)
- Stacked elements (z-index layering)
- Elements outside parent bounds
- Decorative/background elements
## 4. Proposed Algorithm
### Step 1: Classify Elements
```
For each parent with children:
1. Detect overlapping elements (IoU > 0.1) → Keep absolute
2. Remaining elements → Flow elements
```
### Step 2: Detect Layout Direction
```
For flow elements:
1. Analyze horizontal vs vertical distribution
2. Determine primary axis (row or column)
3. Calculate gap consistency
```
### Step 3: Calculate Padding
```
If layout detected:
1. Sort children by primary axis position
2. paddingStart = first child offset from parent start
3. paddingEnd = parent end - last child end
4. Analyze cross-axis for paddingCross
```
### Step 4: Calculate Individual Margins
```
For each flow child:
1. expectedPosition = based on padding + gap + previous elements
2. actualPosition = child's current position
3. If difference > threshold:
- Add margin to child
```
### Step 5: Clean Up Styles
```
For flow children:
1. Remove position: absolute
2. Remove left, top
3. Add margin if calculated
For parent:
1. Add padding
2. Keep gap (already implemented)
3. Keep display: flex/grid
```
## 5. Implementation Considerations
### 5.1 Edge Cases
1. **Negative margins**: When elements overlap slightly
2. **Mixed alignments**: Some elements centered, others not
3. **Variable gaps**: Elements with inconsistent spacing
4. **Percentage values**: May need to convert px to %
### 5.2 Thresholds
| Parameter | Recommended Value | Source |
| --------------------- | ----------------- | -------- |
| Padding detection | >= 0px | Standard |
| Gap consistency CV | <= 20% | imgcook |
| Alignment tolerance | 2px | Common |
| Overlap IoU threshold | 0.1 | imgcook |
### 5.3 Priority Order
1. Grid detection (highest priority for regular grids)
2. Flex detection with padding inference
3. Fall back to absolute positioning
## 6. References
1. **FigmaToCode**: https://github.com/bernaferrari/FigmaToCode
2. **Facebook Yoga**: https://github.com/facebook/yoga
3. **imgcook Blog**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
4. **Allen's Interval Algebra**: https://en.wikipedia.org/wiki/Allen's_interval_algebra
5. **Layout Inference Paper**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
6. **teleportHQ**: https://github.com/teleporthq/teleport-code-generators
7. **Yoga Go Port**: https://github.com/kjk/flex
## 7. Conclusion
The key insight from industry implementations is that converting absolute positioning to relative layouts requires:
1. **Spatial Analysis**: Use Allen's Interval Algebra or similar to understand element relationships
2. **Padding Inference**: Calculate parent padding from first/last child offsets
3. **Margin Calculation**: Handle individual element offsets that don't fit the primary layout
4. **Selective Preservation**: Keep absolute positioning for genuinely overlapping elements
The algorithm should be conservative - only convert when confident about the layout structure, otherwise preserve absolute positioning for accuracy.
```
--------------------------------------------------------------------------------
/tests/fixtures/expected/real-node-data-optimized.json:
--------------------------------------------------------------------------------
```json
{
"name": "Vigilkids产品站",
"lastModified": "2025-12-05T09:47:37Z",
"thumbnailUrl": "https://s3-alpha.figma.com/thumbnails/9a38a8e4-5a00-4c07-a053-e71c7103a167?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQ4GOSFWCXJW6HYPC%2F20251204%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251204T000000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dab594780137ae82433ff7205024276c20a3abb73855f2e393c7e6373086b892",
"nodes": [
{
"id": "2:674",
"name": "Group 1410104851",
"type": "GROUP",
"cssStyles": {
"width": "1580px",
"height": "895px",
"position": "absolute",
"left": "406px",
"top": "422px"
},
"children": [
{
"id": "2:675",
"name": "Rectangle 34",
"type": "RECTANGLE",
"cssStyles": {
"width": "1580px",
"height": "895px",
"position": "absolute",
"left": "0px",
"top": "0px",
"backgroundColor": "#FFFFFF",
"borderRadius": "12px"
}
},
{
"id": "2:676",
"name": "Group 1410104850",
"type": "GROUP",
"cssStyles": {
"width": "350px",
"height": "316px",
"position": "absolute",
"left": "615px",
"top": "232px",
"display": "flex",
"flexDirection": "column",
"gap": "32px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "2:689",
"name": "Group 1410104849",
"type": "GROUP",
"cssStyles": {
"width": "220px",
"height": "138px",
"position": "absolute",
"left": "65px",
"top": "0px"
},
"exportInfo": {
"type": "IMAGE",
"format": "PNG",
"fileName": "group_1410104849.png"
}
},
{
"id": "2:677",
"name": "Group 1410104480",
"type": "GROUP",
"cssStyles": {
"width": "350px",
"height": "148px",
"position": "absolute",
"left": "0px",
"top": "168px",
"display": "flex",
"flexDirection": "column",
"gap": "32px",
"justifyContent": "space-between",
"alignItems": "center"
},
"children": [
{
"id": "2:678",
"name": "添加自定义关键词,当检测到关键词出现时您将接收警报",
"type": "TEXT",
"cssStyles": {
"width": "350px",
"height": "20px",
"position": "absolute",
"left": "0px",
"top": "0px",
"color": "#333333",
"fontFamily": "PingFang SC",
"fontSize": "14px",
"fontWeight": 500,
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "20px"
},
"text": "添加自定义关键词,当检测到关键词出现时您将接收警报"
},
{
"id": "2:679",
"name": "Group 1410104479",
"type": "GROUP",
"cssStyles": {
"width": "240px",
"height": "98px",
"position": "absolute",
"left": "55px",
"top": "50px",
"display": "flex",
"flexDirection": "column",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "2:680",
"name": "Group 1410086131",
"type": "GROUP",
"cssStyles": {
"width": "240px",
"height": "44px",
"position": "absolute",
"left": "0px",
"top": "0px"
},
"children": [
{
"id": "2:681",
"name": "Rectangle 34625783",
"type": "RECTANGLE",
"cssStyles": {
"width": "240px",
"height": "44px",
"position": "absolute",
"left": "0px",
"top": "0px",
"backgroundColor": "#24C790",
"borderRadius": "10px"
}
},
{
"id": "2:682",
"name": "Group 1410104509",
"type": "GROUP",
"cssStyles": {
"width": "115px",
"height": "20px",
"position": "absolute",
"left": "63px",
"top": "12px",
"display": "flex",
"gap": "10px",
"justifyContent": "space-between",
"alignItems": "flex-start"
},
"children": [
{
"id": "2:684",
"name": "Frame",
"type": "FRAME",
"cssStyles": {
"width": "20px",
"height": "20px",
"position": "absolute",
"left": "0px",
"top": "0px"
},
"exportInfo": {
"type": "IMAGE",
"format": "SVG",
"fileName": "frame.svg"
}
},
{
"id": "2:683",
"name": "AI生成关键词",
"type": "TEXT",
"cssStyles": {
"width": "84px",
"height": "16px",
"position": "absolute",
"left": "31px",
"top": "2px",
"color": "#FFFFFF",
"fontFamily": "Roboto",
"fontSize": "14px",
"fontWeight": 700,
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "16px"
},
"text": "AI生成关键词"
}
]
}
]
},
{
"id": "2:686",
"name": "Group 1410104451",
"type": "GROUP",
"cssStyles": {
"width": "240px",
"height": "44px",
"position": "absolute",
"left": "0px",
"top": "54px",
"borderRadius": "10px"
},
"children": [
{
"id": "2:687",
"name": "Rectangle 34625783",
"type": "RECTANGLE",
"cssStyles": {
"width": "240px",
"height": "44px",
"position": "absolute",
"left": "0px",
"top": "0px",
"backgroundColor": "#FFFFFF",
"borderColor": "#C4C4C4",
"borderStyle": "solid",
"borderRadius": "10px"
}
},
{
"id": "2:688",
"name": "添加自定义关键词",
"type": "TEXT",
"cssStyles": {
"width": "146px",
"height": "16px",
"position": "absolute",
"left": "48px",
"top": "14px",
"color": "#333333",
"fontFamily": "Roboto",
"fontSize": "14px",
"fontWeight": 700,
"textAlign": "center",
"verticalAlign": "middle",
"lineHeight": "16px"
},
"text": "添加自定义关键词"
}
]
}
]
}
]
}
]
}
]
}
]
}
```
--------------------------------------------------------------------------------
/tests/unit/algorithms/icon-optimization.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Icon Detection Optimization Tests
*
* Verifies that the optimized collectNodeStats() function produces
* identical results to the original individual functions.
*/
import { describe, it, expect } from "vitest";
// Test the internal implementation
// We'll create mock nodes and verify the stats are correctly computed
interface MockNode {
id: string;
name: string;
type: string;
children?: MockNode[];
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
fills?: Array<{ type: string; visible?: boolean; imageRef?: string }>;
effects?: Array<{ type: string; visible?: boolean }>;
}
// Helper functions to test (matching the original implementations)
const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"];
const MERGEABLE_TYPES = [
"VECTOR",
"RECTANGLE",
"ELLIPSE",
"LINE",
"POLYGON",
"STAR",
"BOOLEAN_OPERATION",
"REGULAR_POLYGON",
];
const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"];
const PNG_REQUIRED_EFFECTS = ["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"];
function isContainerType(type: string): boolean {
return CONTAINER_TYPES.includes(type);
}
function isMergeableType(type: string): boolean {
return MERGEABLE_TYPES.includes(type);
}
function isExcludeType(type: string): boolean {
return EXCLUDE_TYPES.includes(type);
}
function hasImageFill(node: MockNode): boolean {
if (!node.fills) return false;
return node.fills.some(
(fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
);
}
function hasComplexEffects(node: MockNode): boolean {
if (!node.effects) return false;
return node.effects.some(
(effect) => effect.visible !== false && PNG_REQUIRED_EFFECTS.includes(effect.type),
);
}
// Original functions (for comparison)
function calculateDepthOriginal(node: MockNode, currentDepth: number = 0): number {
if (!node.children || node.children.length === 0) {
return currentDepth;
}
return Math.max(...node.children.map((child) => calculateDepthOriginal(child, currentDepth + 1)));
}
function countTotalChildrenOriginal(node: MockNode): number {
if (!node.children || node.children.length === 0) {
return 0;
}
return node.children.reduce((sum, child) => sum + 1 + countTotalChildrenOriginal(child), 0);
}
function hasExcludeTypeInTreeOriginal(node: MockNode): boolean {
if (isExcludeType(node.type)) {
return true;
}
if (node.children) {
return node.children.some((child) => hasExcludeTypeInTreeOriginal(child));
}
return false;
}
function hasImageFillInTreeOriginal(node: MockNode): boolean {
if (hasImageFill(node)) {
return true;
}
if (node.children) {
return node.children.some((child) => hasImageFillInTreeOriginal(child));
}
return false;
}
function hasComplexEffectsInTreeOriginal(node: MockNode): boolean {
if (hasComplexEffects(node)) {
return true;
}
if (node.children) {
return node.children.some((child) => hasComplexEffectsInTreeOriginal(child));
}
return false;
}
function areAllLeavesMergeableOriginal(node: MockNode): boolean {
if (!node.children || node.children.length === 0) {
return isMergeableType(node.type);
}
if (isContainerType(node.type)) {
return node.children.every((child) => areAllLeavesMergeableOriginal(child));
}
return isMergeableType(node.type);
}
// Optimized single-pass function
interface NodeTreeStats {
depth: number;
totalChildren: number;
hasExcludeType: boolean;
hasImageFill: boolean;
hasComplexEffects: boolean;
allLeavesMergeable: boolean;
mergeableRatio: number;
}
function collectNodeStats(node: MockNode): NodeTreeStats {
if (!node.children || node.children.length === 0) {
const isMergeable = isMergeableType(node.type);
return {
depth: 0,
totalChildren: 0,
hasExcludeType: isExcludeType(node.type),
hasImageFill: hasImageFill(node),
hasComplexEffects: hasComplexEffects(node),
allLeavesMergeable: isMergeable,
mergeableRatio: isMergeable ? 1 : 0,
};
}
const childStats = node.children.map(collectNodeStats);
const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
const hasImageInChildren = childStats.some((s) => s.hasImageFill);
const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
const mergeableCount = node.children.filter(
(child) => isMergeableType(child.type) || isContainerType(child.type),
).length;
const mergeableRatio = mergeableCount / node.children.length;
const allLeavesMergeable = isContainerType(node.type)
? allChildrenMergeable
: isMergeableType(node.type);
return {
depth: maxChildDepth + 1,
totalChildren: totalDescendants,
hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
hasImageFill: hasImageFill(node) || hasImageInChildren,
hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
allLeavesMergeable,
mergeableRatio,
};
}
describe("Icon Detection Optimization", () => {
describe("collectNodeStats equivalence", () => {
const testCases: { name: string; node: MockNode }[] = [
{
name: "simple leaf node",
node: {
id: "1",
name: "Vector",
type: "VECTOR",
},
},
{
name: "leaf node with excludable type",
node: {
id: "1",
name: "Text",
type: "TEXT",
},
},
{
name: "container with vector children",
node: {
id: "1",
name: "Group",
type: "GROUP",
children: [
{ id: "2", name: "Vector1", type: "VECTOR" },
{ id: "3", name: "Vector2", type: "VECTOR" },
],
},
},
{
name: "nested container",
node: {
id: "1",
name: "Frame",
type: "FRAME",
children: [
{
id: "2",
name: "Group",
type: "GROUP",
children: [
{ id: "3", name: "Ellipse", type: "ELLIPSE" },
{ id: "4", name: "Rect", type: "RECTANGLE" },
],
},
],
},
},
{
name: "container with text child",
node: {
id: "1",
name: "Button",
type: "FRAME",
children: [
{ id: "2", name: "BG", type: "RECTANGLE" },
{ id: "3", name: "Label", type: "TEXT" },
],
},
},
{
name: "node with image fill",
node: {
id: "1",
name: "Image",
type: "RECTANGLE",
fills: [{ type: "IMAGE", visible: true, imageRef: "abc123" }],
},
},
{
name: "node with complex effects",
node: {
id: "1",
name: "Shadow Box",
type: "FRAME",
effects: [{ type: "DROP_SHADOW", visible: true }],
children: [{ id: "2", name: "Content", type: "RECTANGLE" }],
},
},
{
name: "deeply nested structure",
node: {
id: "1",
name: "Root",
type: "FRAME",
children: [
{
id: "2",
name: "Level1",
type: "GROUP",
children: [
{
id: "3",
name: "Level2",
type: "GROUP",
children: [
{
id: "4",
name: "Level3",
type: "GROUP",
children: [{ id: "5", name: "Leaf", type: "VECTOR" }],
},
],
},
],
},
],
},
},
];
testCases.forEach(({ name, node }) => {
it(`should produce equivalent results for: ${name}`, () => {
const stats = collectNodeStats(node);
// Compare with original functions
expect(stats.depth).toBe(calculateDepthOriginal(node));
expect(stats.totalChildren).toBe(countTotalChildrenOriginal(node));
expect(stats.hasExcludeType).toBe(hasExcludeTypeInTreeOriginal(node));
expect(stats.hasImageFill).toBe(hasImageFillInTreeOriginal(node));
expect(stats.hasComplexEffects).toBe(hasComplexEffectsInTreeOriginal(node));
expect(stats.allLeavesMergeable).toBe(areAllLeavesMergeableOriginal(node));
});
});
});
describe("edge cases", () => {
it("should handle empty children array", () => {
const node: MockNode = {
id: "1",
name: "Empty",
type: "GROUP",
children: [],
};
const stats = collectNodeStats(node);
expect(stats.depth).toBe(0);
expect(stats.totalChildren).toBe(0);
});
it("should handle invisible fills", () => {
const node: MockNode = {
id: "1",
name: "Hidden Image",
type: "RECTANGLE",
fills: [{ type: "IMAGE", visible: false, imageRef: "abc123" }],
};
const stats = collectNodeStats(node);
expect(stats.hasImageFill).toBe(false);
});
it("should handle invisible effects", () => {
const node: MockNode = {
id: "1",
name: "Hidden Shadow",
type: "RECTANGLE",
effects: [{ type: "DROP_SHADOW", visible: false }],
};
const stats = collectNodeStats(node);
expect(stats.hasComplexEffects).toBe(false);
});
it("should calculate correct mergeable ratio", () => {
const node: MockNode = {
id: "1",
name: "Mixed",
type: "FRAME",
children: [
{ id: "2", name: "V1", type: "VECTOR" },
{ id: "3", name: "V2", type: "VECTOR" },
{ id: "4", name: "Unknown", type: "UNKNOWN_TYPE" },
{ id: "5", name: "G1", type: "GROUP" },
],
};
const stats = collectNodeStats(node);
// 3 mergeable (2 VECTOR + 1 GROUP) out of 4
expect(stats.mergeableRatio).toBe(0.75);
});
});
});
```
--------------------------------------------------------------------------------
/tests/unit/services/cache.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Cache Manager Unit Tests
*
* Tests the multi-layer caching system for Figma API responses and images.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { CacheManager } from "~/services/cache/index.js";
describe("CacheManager", () => {
let cacheManager: CacheManager;
let testCacheDir: string;
beforeEach(() => {
// Create a unique temporary directory for each test
testCacheDir = path.join(os.tmpdir(), `figma-cache-test-${Date.now()}`);
cacheManager = new CacheManager({
enabled: true,
memory: {
maxNodeItems: 100,
maxImageItems: 50,
nodeTTL: 1000, // 1 second TTL for testing
imageTTL: 1000,
},
disk: {
cacheDir: testCacheDir,
maxSize: 100 * 1024 * 1024,
ttl: 1000, // 1 second TTL for testing
},
});
});
afterEach(() => {
// Clean up test cache directory
if (fs.existsSync(testCacheDir)) {
fs.rmSync(testCacheDir, { recursive: true, force: true });
}
});
describe("Configuration", () => {
it("should create cache directories on initialization", () => {
expect(fs.existsSync(testCacheDir)).toBe(true);
expect(fs.existsSync(path.join(testCacheDir, "data"))).toBe(true);
expect(fs.existsSync(path.join(testCacheDir, "images"))).toBe(true);
expect(fs.existsSync(path.join(testCacheDir, "metadata"))).toBe(true);
});
it("should not create directories when disabled", () => {
const disabledCacheDir = path.join(os.tmpdir(), `figma-cache-disabled-${Date.now()}`);
new CacheManager({
enabled: false,
disk: {
cacheDir: disabledCacheDir,
maxSize: 100 * 1024 * 1024,
ttl: 1000,
},
});
expect(fs.existsSync(disabledCacheDir)).toBe(false);
});
it("should return correct cache stats", async () => {
const stats = await cacheManager.getStats();
expect(stats.enabled).toBe(true);
expect(stats.memory.size).toBe(0);
expect(stats.disk.nodeFileCount).toBe(0);
expect(stats.disk.imageFileCount).toBe(0);
expect(stats.disk.totalSize).toBe(0);
});
it("should return cache directory", () => {
expect(cacheManager.getCacheDir()).toBe(testCacheDir);
});
it("should report enabled status", () => {
expect(cacheManager.isEnabled()).toBe(true);
});
});
describe("Node Data Caching", () => {
const testData = { id: "123", name: "Test Node", type: "FRAME" };
const fileKey = "test-file-key";
it("should cache and retrieve node data", async () => {
await cacheManager.setNodeData(testData, fileKey);
const cached = await cacheManager.getNodeData(fileKey);
expect(cached).toEqual(testData);
});
it("should cache with nodeId parameter", async () => {
const nodeId = "node-456";
await cacheManager.setNodeData(testData, fileKey, nodeId);
const cached = await cacheManager.getNodeData(fileKey, nodeId);
expect(cached).toEqual(testData);
});
it("should cache with depth parameter", async () => {
const nodeId = "node-789";
const depth = 3;
await cacheManager.setNodeData(testData, fileKey, nodeId, depth);
const cached = await cacheManager.getNodeData(fileKey, nodeId, depth);
expect(cached).toEqual(testData);
});
it("should return null for non-existent cache", async () => {
const cached = await cacheManager.getNodeData("non-existent-key");
expect(cached).toBeNull();
});
it("should return null for expired cache", async () => {
await cacheManager.setNodeData(testData, fileKey);
// Wait for cache to expire (TTL is 1 second)
await new Promise((resolve) => setTimeout(resolve, 1100));
const cached = await cacheManager.getNodeData(fileKey);
expect(cached).toBeNull();
});
it("should update cache stats after caching data", async () => {
await cacheManager.setNodeData(testData, fileKey);
const stats = await cacheManager.getStats();
expect(stats.memory.size).toBe(1);
expect(stats.disk.nodeFileCount).toBe(1);
expect(stats.disk.totalSize).toBeGreaterThan(0);
});
it("should check if node data exists", async () => {
expect(await cacheManager.hasNodeData(fileKey)).toBe(false);
await cacheManager.setNodeData(testData, fileKey);
expect(await cacheManager.hasNodeData(fileKey)).toBe(true);
});
});
describe("Image Caching", () => {
const fileKey = "test-file";
const nodeId = "image-node";
const format = "png";
let testImagePath: string;
beforeEach(() => {
// Create a test image file
testImagePath = path.join(os.tmpdir(), `test-image-${Date.now()}.png`);
fs.writeFileSync(testImagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
});
afterEach(() => {
if (fs.existsSync(testImagePath)) {
fs.unlinkSync(testImagePath);
}
});
it("should return null for uncached image", async () => {
const result = await cacheManager.hasImage(fileKey, nodeId, format);
expect(result).toBeNull();
});
it("should cache and find image", async () => {
await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
expect(cachedPath).not.toBeNull();
expect(fs.existsSync(cachedPath!)).toBe(true);
});
it("should copy image from cache to target path", async () => {
await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
const success = await cacheManager.copyImageFromCache(fileKey, nodeId, format, targetPath);
expect(success).toBe(true);
expect(fs.existsSync(targetPath)).toBe(true);
// Clean up
fs.unlinkSync(targetPath);
});
it("should return false when copying non-existent image", async () => {
const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
const success = await cacheManager.copyImageFromCache(
"non-existent",
"non-existent",
format,
targetPath,
);
expect(success).toBe(false);
});
it("should update cache stats after caching image", async () => {
await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
const stats = await cacheManager.getStats();
expect(stats.disk.imageFileCount).toBe(1);
});
});
describe("Cache Cleanup", () => {
it("should clean expired cache entries", async () => {
const testData = { id: "test" };
await cacheManager.setNodeData(testData, "file-1");
// Wait for cache to expire
await new Promise((resolve) => setTimeout(resolve, 1100));
const result = await cacheManager.cleanExpired();
expect(result.disk).toBeGreaterThanOrEqual(1);
const stats = await cacheManager.getStats();
expect(stats.disk.nodeFileCount).toBe(0);
});
it("should clear all cache", async () => {
await cacheManager.setNodeData({ id: "1" }, "file-1");
await cacheManager.setNodeData({ id: "2" }, "file-2");
await cacheManager.clearAll();
const stats = await cacheManager.getStats();
expect(stats.memory.size).toBe(0);
expect(stats.disk.nodeFileCount).toBe(0);
expect(stats.disk.imageFileCount).toBe(0);
});
});
describe("Cache Invalidation", () => {
it("should invalidate all entries for a file", async () => {
const fileKey = "test-file";
await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
await cacheManager.setNodeData({ id: "3" }, "other-file", "node-3");
const result = await cacheManager.invalidateFile(fileKey);
expect(result.memory).toBe(2);
expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
expect(await cacheManager.getNodeData(fileKey, "node-2")).toBeNull();
// Other file should still be cached
expect(await cacheManager.getNodeData("other-file", "node-3")).not.toBeNull();
});
it("should invalidate a specific node", async () => {
const fileKey = "test-file";
await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
const result = await cacheManager.invalidateNode(fileKey, "node-1");
expect(result.memory).toBe(1);
expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
expect(await cacheManager.getNodeData(fileKey, "node-2")).not.toBeNull();
});
});
describe("Disabled Cache", () => {
let disabledCacheManager: CacheManager;
beforeEach(() => {
disabledCacheManager = new CacheManager({ enabled: false });
});
it("should return null for getNodeData when disabled", async () => {
const result = await disabledCacheManager.getNodeData("any-key");
expect(result).toBeNull();
});
it("should do nothing for setNodeData when disabled", async () => {
await disabledCacheManager.setNodeData({ id: "test" }, "file-key");
const result = await disabledCacheManager.getNodeData("file-key");
expect(result).toBeNull();
});
it("should return null for hasImage when disabled", async () => {
const result = await disabledCacheManager.hasImage("file", "node", "png");
expect(result).toBeNull();
});
it("should return source path for cacheImage when disabled", async () => {
const sourcePath = "/path/to/image.png";
const result = await disabledCacheManager.cacheImage(sourcePath, "file", "node", "png");
expect(result).toBe(sourcePath);
});
it("should return zero for cleanExpired when disabled", async () => {
const result = await disabledCacheManager.cleanExpired();
expect(result.memory).toBe(0);
expect(result.disk).toBe(0);
});
it("should report disabled in stats", async () => {
const stats = await disabledCacheManager.getStats();
expect(stats.enabled).toBe(false);
});
it("should report disabled status", () => {
expect(disabledCacheManager.isEnabled()).toBe(false);
});
});
describe("Memory Cache (L1)", () => {
it("should serve from memory cache on second read", async () => {
const testData = { id: "memory-test" };
const fileKey = "memory-file";
await cacheManager.setNodeData(testData, fileKey);
// First read - populates memory from disk if needed
const first = await cacheManager.getNodeData(fileKey);
expect(first).toEqual(testData);
// Second read should hit memory cache
const second = await cacheManager.getNodeData(fileKey);
expect(second).toEqual(testData);
const stats = await cacheManager.getStats();
expect(stats.memory.hits).toBeGreaterThan(0);
});
it("should track cache statistics", async () => {
// Initial stats
let stats = await cacheManager.getStats();
expect(stats.memory.hits).toBe(0);
expect(stats.memory.misses).toBe(0);
// Miss
await cacheManager.getNodeData("non-existent");
stats = await cacheManager.getStats();
expect(stats.memory.misses).toBe(1);
// Set and hit
await cacheManager.setNodeData({ test: 1 }, "key");
await cacheManager.getNodeData("key");
stats = await cacheManager.getStats();
expect(stats.memory.hits).toBe(1);
});
it("should reset statistics", async () => {
await cacheManager.setNodeData({ test: 1 }, "key");
await cacheManager.getNodeData("key");
await cacheManager.getNodeData("non-existent");
cacheManager.resetStats();
const stats = await cacheManager.getStats();
expect(stats.memory.hits).toBe(0);
expect(stats.memory.misses).toBe(0);
});
});
});
```
--------------------------------------------------------------------------------
/tests/integration/output-quality.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Output Quality Validation Tests
*
* Tests the quality of optimized output for redundancy, consistency, and correctness.
* Converted from scripts/analyze-optimized-output.ts
*/
import { describe, it, expect, beforeAll } from "vitest";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import { parseFigmaResponse } from "~/core/parser.js";
import type { SimplifiedNode } from "~/types/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
// Test file configurations
const TEST_FILES = [
{ name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
{ name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
];
// Quality analysis interfaces
interface QualityAnalysis {
totalNodes: number;
nodesByType: Record<string, number>;
layoutStats: {
flex: number;
grid: number;
absolute: number;
none: number;
};
cssPropertyUsage: Record<string, number>;
redundantPatterns: RedundantPattern[];
emptyOrDefaultValues: EmptyValue[];
issues: QualityIssue[];
}
interface RedundantPattern {
nodeName: string;
pattern: string;
details: string;
}
interface EmptyValue {
nodeName: string;
property: string;
value: string;
}
interface QualityIssue {
nodeName: string;
nodeType: string;
issue: string;
severity: "warning" | "error";
}
// Helper: Analyze a node recursively
function analyzeNode(node: SimplifiedNode, result: QualityAnalysis, parentLayout?: string): void {
result.totalNodes++;
// Count node types
result.nodesByType[node.type] = (result.nodesByType[node.type] || 0) + 1;
// Count layout types
const display = node.cssStyles?.display;
if (display === "flex") {
result.layoutStats.flex++;
} else if (display === "grid") {
result.layoutStats.grid++;
} else if (node.cssStyles?.position === "absolute") {
result.layoutStats.absolute++;
} else {
result.layoutStats.none++;
}
// Analyze CSS properties
if (node.cssStyles) {
for (const [key, value] of Object.entries(node.cssStyles)) {
if (value !== undefined && value !== null && value !== "") {
result.cssPropertyUsage[key] = (result.cssPropertyUsage[key] || 0) + 1;
}
// Check for empty or default values
if (value === "" || value === "0" || value === "0px" || value === "none") {
result.emptyOrDefaultValues.push({
nodeName: node.name,
property: key,
value: String(value),
});
}
// Check for redundant patterns
// 1. position: absolute inside flex/grid parent
if (key === "position" && value === "absolute" && parentLayout) {
if (parentLayout === "flex" || parentLayout === "grid") {
result.redundantPatterns.push({
nodeName: node.name,
pattern: "absolute-in-layout",
details: `position:absolute inside ${parentLayout} parent`,
});
}
}
// 2. width with flex property (potential conflict)
if (key === "width" && node.cssStyles?.flex) {
result.redundantPatterns.push({
nodeName: node.name,
pattern: "width-with-flex",
details: "width specified with flex property",
});
}
}
}
// Check for quality issues
// 1. TEXT node with layout properties
if (node.type === "TEXT" && (display === "flex" || display === "grid")) {
result.issues.push({
nodeName: node.name,
nodeType: node.type,
issue: `TEXT node with ${display} layout (unnecessary)`,
severity: "warning",
});
}
// 2. Empty children array
if (node.children && node.children.length === 0) {
result.issues.push({
nodeName: node.name,
nodeType: node.type,
issue: "Empty children array (should be removed)",
severity: "warning",
});
}
// 3. VECTOR/ELLIPSE without exportInfo
if ((node.type === "VECTOR" || node.type === "ELLIPSE") && !node.exportInfo) {
result.issues.push({
nodeName: node.name,
nodeType: node.type,
issue: `${node.type} without exportInfo (image not exported)`,
severity: "warning",
});
}
// Recurse into children
if (node.children) {
const currentLayout =
display || (node.cssStyles?.position === "absolute" ? "absolute" : undefined);
for (const child of node.children) {
analyzeNode(child, result, currentLayout);
}
}
}
// Helper: Analyze a parsed result
function analyzeOutput(result: ReturnType<typeof parseFigmaResponse>): QualityAnalysis {
const analysis: QualityAnalysis = {
totalNodes: 0,
nodesByType: {},
layoutStats: { flex: 0, grid: 0, absolute: 0, none: 0 },
cssPropertyUsage: {},
redundantPatterns: [],
emptyOrDefaultValues: [],
issues: [],
};
for (const node of result.nodes) {
analyzeNode(node, analysis);
}
return analysis;
}
// Helper: Load and parse fixture
function loadAndParse(name: string): ReturnType<typeof parseFigmaResponse> {
const filePath = path.join(fixturesDir, `${name}.json`);
const rawData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
return parseFigmaResponse(rawData);
}
describe("Output Quality Validation", () => {
TEST_FILES.forEach(({ name, desc }) => {
describe(`${name} (${desc})`, () => {
let result: ReturnType<typeof parseFigmaResponse>;
let analysis: QualityAnalysis;
beforeAll(() => {
const filePath = path.join(fixturesDir, `${name}.json`);
if (!fs.existsSync(filePath)) {
throw new Error(`Test fixture not found: ${name}.json`);
}
result = loadAndParse(name);
analysis = analyzeOutput(result);
});
describe("Node Structure", () => {
it("should have non-zero node count", () => {
expect(analysis.totalNodes).toBeGreaterThan(0);
});
it("should have diverse node types", () => {
const typeCount = Object.keys(analysis.nodesByType).length;
expect(typeCount).toBeGreaterThan(1);
});
it("should have reasonable node type distribution", () => {
// No single type should dominate excessively (>90%)
const maxTypeCount = Math.max(...Object.values(analysis.nodesByType));
const dominanceRatio = maxTypeCount / analysis.totalNodes;
expect(dominanceRatio).toBeLessThan(0.9);
});
});
describe("Layout Quality", () => {
it("should use semantic layouts (flex/grid)", () => {
const semanticLayouts = analysis.layoutStats.flex + analysis.layoutStats.grid;
expect(semanticLayouts).toBeGreaterThan(0);
});
it("should have reasonable absolute positioning ratio", () => {
const absoluteRatio = analysis.layoutStats.absolute / analysis.totalNodes;
// Warning if >80% absolute (but not a hard failure for all fixtures)
expect(absoluteRatio).toBeLessThan(0.95);
});
});
describe("CSS Property Quality", () => {
it("should have essential CSS properties", () => {
// Width and height should be commonly used
const hasWidth = (analysis.cssPropertyUsage["width"] || 0) > 0;
const hasHeight = (analysis.cssPropertyUsage["height"] || 0) > 0;
expect(hasWidth || hasHeight).toBe(true);
});
it("should not have excessive empty or default values", () => {
// Empty values should be less than 50% of total nodes
// Note: Some default values like "0px" may be intentional for clarity
const emptyRatio = analysis.emptyOrDefaultValues.length / analysis.totalNodes;
expect(emptyRatio).toBeLessThan(0.5);
});
it("should have consistent property usage", () => {
// If display is used, it should be meaningful
const displayCount = analysis.cssPropertyUsage["display"] || 0;
if (displayCount > 0) {
// Display should be on containers, not every node
expect(displayCount).toBeLessThan(analysis.totalNodes);
}
});
});
describe("Redundancy Check", () => {
it("should minimize position:absolute inside flex/grid children", () => {
const absoluteInLayout = analysis.redundantPatterns.filter(
(p) => p.pattern === "absolute-in-layout",
);
// Allow some absolute positioning for:
// - Overlapping elements that need stacking
// - Non-homogeneous elements in grid containers (e.g., tabs, dividers)
// These are intentionally kept absolute to preserve their original position
const ratio = absoluteInLayout.length / analysis.totalNodes;
expect(ratio).toBeLessThan(0.1); // Allow up to 10%
});
it("should not have conflicting width and flex properties", () => {
const widthWithFlex = analysis.redundantPatterns.filter(
(p) => p.pattern === "width-with-flex",
);
// Warning level - not necessarily wrong but worth noting
// Allow up to 5% of nodes to have this pattern
const ratio = widthWithFlex.length / analysis.totalNodes;
expect(ratio).toBeLessThan(0.05);
});
});
describe("Quality Issues", () => {
it("should not have TEXT nodes with layout properties", () => {
const textWithLayout = analysis.issues.filter(
(i) => i.nodeType === "TEXT" && i.issue.includes("layout"),
);
expect(textWithLayout.length).toBe(0);
});
it("should not have empty children arrays", () => {
const emptyChildren = analysis.issues.filter((i) => i.issue.includes("Empty children"));
expect(emptyChildren.length).toBe(0);
});
it("should have exportInfo for vector graphics", () => {
const vectorsWithoutExport = analysis.issues.filter((i) =>
i.issue.includes("without exportInfo"),
);
// Allow some vectors without export (decorative elements)
const vectorCount =
(analysis.nodesByType["VECTOR"] || 0) + (analysis.nodesByType["ELLIPSE"] || 0);
if (vectorCount > 0) {
const missingExportRatio = vectorsWithoutExport.length / vectorCount;
expect(missingExportRatio).toBeLessThan(0.5);
}
});
});
describe("Output Statistics", () => {
it("should produce consistent layout statistics", () => {
// Snapshot the statistics for regression detection
expect({
totalNodes: analysis.totalNodes,
flexCount: analysis.layoutStats.flex,
gridCount: analysis.layoutStats.grid,
absoluteCount: analysis.layoutStats.absolute,
issueCount: analysis.issues.length,
redundantCount: analysis.redundantPatterns.length,
}).toMatchSnapshot();
});
});
});
});
describe("Cross-fixture Consistency", () => {
let analyses: Map<string, QualityAnalysis>;
beforeAll(() => {
analyses = new Map();
TEST_FILES.forEach(({ name }) => {
const filePath = path.join(fixturesDir, `${name}.json`);
if (fs.existsSync(filePath)) {
const result = loadAndParse(name);
analyses.set(name, analyzeOutput(result));
}
});
});
it("should use consistent CSS properties across fixtures", () => {
const allProperties = new Set<string>();
analyses.forEach((analysis) => {
Object.keys(analysis.cssPropertyUsage).forEach((prop) => {
allProperties.add(prop);
});
});
// Essential properties should appear in all fixtures
const essentialProps = ["width", "height"];
essentialProps.forEach((prop) => {
let count = 0;
analyses.forEach((analysis) => {
if (analysis.cssPropertyUsage[prop]) count++;
});
expect(count).toBeGreaterThan(0);
});
});
it("should have similar quality metrics across fixtures", () => {
const qualityScores: number[] = [];
analyses.forEach((analysis) => {
// Quality score: higher is better
const semanticRatio =
(analysis.layoutStats.flex + analysis.layoutStats.grid) / analysis.totalNodes;
const issueRatio = analysis.issues.length / analysis.totalNodes;
const score = semanticRatio * 100 - issueRatio * 50;
qualityScores.push(score);
});
// All fixtures should have non-negative quality scores
qualityScores.forEach((score) => {
expect(score).toBeGreaterThanOrEqual(-10);
});
});
});
});
```