This is page 2 of 8. Use http://codebase.md/1yhy/figma-context-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .env.example
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .lintstagedrc.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── Dockerfile
├── docs
│ ├── en
│ │ ├── absolute-to-relative-research.md
│ │ ├── architecture.md
│ │ ├── cache-architecture.md
│ │ ├── grid-layout-research.md
│ │ ├── icon-detection.md
│ │ ├── layout-detection-research.md
│ │ └── layout-detection.md
│ └── zh-CN
│ ├── absolute-to-relative-research.md
│ ├── architecture.md
│ ├── cache-architecture.md
│ ├── grid-layout-research.md
│ ├── icon-detection.md
│ ├── layout-detection-research.md
│ ├── layout-detection.md
│ └── TODO-feature-enhancements.md
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── README.zh-CN.md
├── scripts
│ ├── fetch-test-data.ts
│ └── optimize-figma-json.ts
├── smithery.yaml
├── src
│ ├── algorithms
│ │ ├── icon
│ │ │ ├── detector.ts
│ │ │ └── index.ts
│ │ └── layout
│ │ ├── detector.ts
│ │ ├── index.ts
│ │ ├── optimizer.ts
│ │ └── spatial.ts
│ ├── config.ts
│ ├── core
│ │ ├── effects.ts
│ │ ├── layout.ts
│ │ ├── parser.ts
│ │ └── style.ts
│ ├── index.ts
│ ├── prompts
│ │ ├── design-to-code.ts
│ │ └── index.ts
│ ├── resources
│ │ ├── figma-resources.ts
│ │ └── index.ts
│ ├── server.ts
│ ├── services
│ │ ├── cache
│ │ │ ├── cache-manager.ts
│ │ │ ├── disk-cache.ts
│ │ │ ├── index.ts
│ │ │ ├── lru-cache.ts
│ │ │ └── types.ts
│ │ ├── cache.ts
│ │ ├── figma.ts
│ │ └── simplify-node-response.ts
│ ├── types
│ │ ├── figma.ts
│ │ ├── index.ts
│ │ └── simplified.ts
│ └── utils
│ ├── color.ts
│ ├── css.ts
│ ├── file.ts
│ └── validation.ts
├── tests
│ ├── fixtures
│ │ ├── expected
│ │ │ ├── node-240-32163-optimized.json
│ │ │ ├── node-402-34955-optimized.json
│ │ │ └── real-node-data-optimized.json
│ │ └── figma-data
│ │ ├── node-240-32163.json
│ │ ├── node-402-34955.json
│ │ └── real-node-data.json
│ ├── integration
│ │ ├── __snapshots__
│ │ │ ├── layout-optimization.test.ts.snap
│ │ │ └── output-quality.test.ts.snap
│ │ ├── layout-optimization.test.ts
│ │ ├── output-quality.test.ts
│ │ └── parser.test.ts
│ ├── unit
│ │ ├── algorithms
│ │ │ ├── icon-optimization.test.ts
│ │ │ ├── icon.test.ts
│ │ │ └── layout.test.ts
│ │ ├── resources
│ │ │ └── figma-resources.test.ts
│ │ └── services
│ │ └── cache.test.ts
│ └── utils
│ ├── preview-generator.ts
│ ├── preview.ts
│ ├── run-simplification.ts
│ └── viewer.html
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/docs/en/absolute-to-relative-research.md:
--------------------------------------------------------------------------------
```markdown
1 | # Absolute Position to Margin/Padding Conversion Research
2 |
3 | ## Research Overview
4 |
5 | This document summarizes industry implementations and academic research on converting absolute positioning (`position: absolute` + `left/top`) to relative layouts (`margin`, `padding`, `gap`).
6 |
7 | ## 1. Industry Implementations
8 |
9 | ### 1.1 FigmaToCode (bernaferrari/FigmaToCode)
10 |
11 | **Approach**: AltNodes Intermediate Representation
12 |
13 | **Key Points**:
14 |
15 | - Uses a 4-stage transformation pipeline:
16 |
17 | 1. Node Conversion - Figma nodes → JSON with optimizations
18 | 2. Intermediate Representation - JSON → AltNodes (virtual DOM)
19 | 3. Layout Optimization - Detect auto-layouts, responsive constraints
20 | 4. Code Generation - Framework-specific output
21 |
22 | - For complex layouts (absolute + auto-layout), makes "intelligent decisions about structure"
23 | - Detects parent-child relationships and z-index ordering
24 | - Uses `insets` for best cases, `left/top` for worst cases
25 |
26 | **Source**: https://github.com/bernaferrari/FigmaToCode
27 |
28 | ### 1.2 Facebook Yoga Layout Engine
29 |
30 | **Approach**: CSS Flexbox Implementation in C++
31 |
32 | **Padding/Margin Calculation**:
33 |
34 | ```
35 | paddingAndBorderForAxis = leadingPaddingAndBorder + trailingPaddingAndBorder
36 | marginForAxis = leadingMargin + trailingMargin
37 | ```
38 |
39 | **Resolution Algorithm**:
40 |
41 | - UnitPoint: Direct pixel value
42 | - UnitPercent: `value * parentSize / 100`
43 | - UnitAuto: Returns 0 for margins
44 |
45 | **Key Functions**:
46 |
47 | - `nodeLeadingPadding()` - Leading edge padding
48 | - `nodeTrailingPadding()` - Trailing edge padding
49 | - `nodeMarginForAxis()` - Total margin for axis
50 | - `resolveValue()` - Unit conversion
51 |
52 | **Source**: https://github.com/facebook/yoga
53 |
54 | ### 1.3 imgcook (Alibaba)
55 |
56 | **Approach**: Rule-based + CV-based Layout Algorithm
57 |
58 | **Key Points**:
59 |
60 | - Converts design layers to flat JSON with absolute positions
61 | - Uses rule-based algorithms to merge adjacent rows/blocks
62 | - CV-based approach for generalization (pixel-level comparison)
63 | - No specific formulas disclosed for margin/padding calculation
64 |
65 | **Limitation**: Implementation details not publicly available
66 |
67 | **Source**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
68 |
69 | ### 1.4 teleportHQ UIDL
70 |
71 | **Approach**: Abstract UI Description Language
72 |
73 | **Key Points**:
74 |
75 | - CSS-like style properties in UIDL
76 | - Design tokens for spacing constants (→ CSS variables)
77 | - Does not appear to handle coordinate-based conversion
78 |
79 | **Source**: https://github.com/teleporthq/teleport-code-generators
80 |
81 | ## 2. Academic Research
82 |
83 | ### 2.1 Layout Inference Algorithm for GUIs
84 |
85 | **Paper**: "A layout inference algorithm for Graphical User Interfaces" (2015)
86 |
87 | **Approach**: Two-phase algorithm using Allen's Interval Algebra
88 |
89 | **Phase 1: Coordinate → Relative Positioning**
90 |
91 | - Change coordinate-based positioning to relative positioning
92 | - Use directed graphs and Allen relations
93 | - Build spatial relationships between elements
94 |
95 | **Phase 2: Pattern Matching & Graph Rewriting**
96 |
97 | - Apply exploratory algorithm
98 | - Pattern matching to identify layout structures
99 | - Graph rewriting to obtain layout solutions
100 |
101 | **Results**:
102 |
103 | - 97% faithful to original views
104 | - 84% maintain proportions when resized
105 |
106 | **Source**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
107 |
108 | ### 2.2 Allen's Interval Algebra
109 |
110 | **13 Basic Relations** (applicable to spatial layout):
111 |
112 | | Relation | Symbol | Description |
113 | | ------------- | ------ | ----------------------------------------------- |
114 | | Precedes | p | A ends before B starts (gap between) |
115 | | Meets | m | A ends exactly where B starts |
116 | | Overlaps | o | A starts before B, they overlap, B ends after A |
117 | | Finished-by | F | B starts during A, ends together |
118 | | Contains | D | A completely contains B |
119 | | Starts | s | A and B start together, A ends first |
120 | | Equals | e | A and B identical |
121 | | Started-by | S | B starts when A starts, B ends after |
122 | | During | d | B completely contains A |
123 | | Finishes | f | A starts during B, ends together |
124 | | Overlapped-by | O | B starts before A, they overlap |
125 | | Met-by | M | A starts exactly where B ends |
126 | | Preceded-by | P | B ends before A starts (gap between) |
127 |
128 | **Application**: Determine spatial relationships to infer layout structure
129 |
130 | **Source**: https://ics.uci.edu/~alspaugh/cls/shr/allen.html
131 |
132 | ## 3. Key Insights
133 |
134 | ### 3.1 Padding Inference Formula
135 |
136 | When a parent container has children, padding can be inferred:
137 |
138 | ```
139 | paddingTop = firstChild.y - parent.y
140 | paddingLeft = firstChild.x - parent.x
141 | paddingBottom = (parent.y + parent.height) - (lastChild.y + lastChild.height)
142 | paddingRight = (parent.x + parent.width) - (lastChild.x + lastChild.width)
143 | ```
144 |
145 | **For Row Layout** (flex-direction: row):
146 |
147 | - Sort children by X position
148 | - `paddingLeft` = first child's left offset from parent
149 | - `paddingTop` = minimum top offset among children
150 | - `gap` = consistent spacing between children (already implemented)
151 |
152 | **For Column Layout** (flex-direction: column):
153 |
154 | - Sort children by Y position
155 | - `paddingTop` = first child's top offset from parent
156 | - `paddingLeft` = minimum left offset among children
157 | - `gap` = consistent spacing between children (already implemented)
158 |
159 | ### 3.2 Individual Margin Calculation
160 |
161 | For elements that don't align perfectly with the primary axis:
162 |
163 | ```
164 | For Row Layout:
165 | expectedY = parent.y + paddingTop
166 | marginTop = child.y - expectedY
167 |
168 | For Column Layout:
169 | expectedX = parent.x + paddingLeft
170 | marginLeft = child.x - expectedX
171 | ```
172 |
173 | ### 3.3 Cross-Axis Alignment vs Margin
174 |
175 | When elements have different cross-axis positions:
176 |
177 | **Option A: Use align-items + individual margins**
178 |
179 | ```css
180 | .parent {
181 | display: flex;
182 | align-items: flex-start;
183 | }
184 | .child-offset {
185 | margin-top: 10px; /* Individual offset */
186 | }
187 | ```
188 |
189 | **Option B: Use align-items: center/stretch**
190 | If all elements are centered or stretched, no individual margins needed.
191 |
192 | ### 3.4 Absolute Position Preservation
193 |
194 | Some elements MUST keep absolute positioning:
195 |
196 | - Overlapping elements (IoU > 0.1)
197 | - Stacked elements (z-index layering)
198 | - Elements outside parent bounds
199 | - Decorative/background elements
200 |
201 | ## 4. Proposed Algorithm
202 |
203 | ### Step 1: Classify Elements
204 |
205 | ```
206 | For each parent with children:
207 | 1. Detect overlapping elements (IoU > 0.1) → Keep absolute
208 | 2. Remaining elements → Flow elements
209 | ```
210 |
211 | ### Step 2: Detect Layout Direction
212 |
213 | ```
214 | For flow elements:
215 | 1. Analyze horizontal vs vertical distribution
216 | 2. Determine primary axis (row or column)
217 | 3. Calculate gap consistency
218 | ```
219 |
220 | ### Step 3: Calculate Padding
221 |
222 | ```
223 | If layout detected:
224 | 1. Sort children by primary axis position
225 | 2. paddingStart = first child offset from parent start
226 | 3. paddingEnd = parent end - last child end
227 | 4. Analyze cross-axis for paddingCross
228 | ```
229 |
230 | ### Step 4: Calculate Individual Margins
231 |
232 | ```
233 | For each flow child:
234 | 1. expectedPosition = based on padding + gap + previous elements
235 | 2. actualPosition = child's current position
236 | 3. If difference > threshold:
237 | - Add margin to child
238 | ```
239 |
240 | ### Step 5: Clean Up Styles
241 |
242 | ```
243 | For flow children:
244 | 1. Remove position: absolute
245 | 2. Remove left, top
246 | 3. Add margin if calculated
247 |
248 | For parent:
249 | 1. Add padding
250 | 2. Keep gap (already implemented)
251 | 3. Keep display: flex/grid
252 | ```
253 |
254 | ## 5. Implementation Considerations
255 |
256 | ### 5.1 Edge Cases
257 |
258 | 1. **Negative margins**: When elements overlap slightly
259 | 2. **Mixed alignments**: Some elements centered, others not
260 | 3. **Variable gaps**: Elements with inconsistent spacing
261 | 4. **Percentage values**: May need to convert px to %
262 |
263 | ### 5.2 Thresholds
264 |
265 | | Parameter | Recommended Value | Source |
266 | | --------------------- | ----------------- | -------- |
267 | | Padding detection | >= 0px | Standard |
268 | | Gap consistency CV | <= 20% | imgcook |
269 | | Alignment tolerance | 2px | Common |
270 | | Overlap IoU threshold | 0.1 | imgcook |
271 |
272 | ### 5.3 Priority Order
273 |
274 | 1. Grid detection (highest priority for regular grids)
275 | 2. Flex detection with padding inference
276 | 3. Fall back to absolute positioning
277 |
278 | ## 6. References
279 |
280 | 1. **FigmaToCode**: https://github.com/bernaferrari/FigmaToCode
281 | 2. **Facebook Yoga**: https://github.com/facebook/yoga
282 | 3. **imgcook Blog**: https://www.alibabacloud.com/blog/imgcook-3-0-series-layout-algorithm-design-based-code-generation_597856
283 | 4. **Allen's Interval Algebra**: https://en.wikipedia.org/wiki/Allen's_interval_algebra
284 | 5. **Layout Inference Paper**: https://www.sciencedirect.com/science/article/abs/pii/S0950584915001718
285 | 6. **teleportHQ**: https://github.com/teleporthq/teleport-code-generators
286 | 7. **Yoga Go Port**: https://github.com/kjk/flex
287 |
288 | ## 7. Conclusion
289 |
290 | The key insight from industry implementations is that converting absolute positioning to relative layouts requires:
291 |
292 | 1. **Spatial Analysis**: Use Allen's Interval Algebra or similar to understand element relationships
293 | 2. **Padding Inference**: Calculate parent padding from first/last child offsets
294 | 3. **Margin Calculation**: Handle individual element offsets that don't fit the primary layout
295 | 4. **Selective Preservation**: Keep absolute positioning for genuinely overlapping elements
296 |
297 | The algorithm should be conservative - only convert when confident about the layout structure, otherwise preserve absolute positioning for accuracy.
298 |
```
--------------------------------------------------------------------------------
/tests/fixtures/expected/real-node-data-optimized.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "Vigilkids产品站",
3 | "lastModified": "2025-12-05T09:47:37Z",
4 | "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",
5 | "nodes": [
6 | {
7 | "id": "2:674",
8 | "name": "Group 1410104851",
9 | "type": "GROUP",
10 | "cssStyles": {
11 | "width": "1580px",
12 | "height": "895px",
13 | "position": "absolute",
14 | "left": "406px",
15 | "top": "422px"
16 | },
17 | "children": [
18 | {
19 | "id": "2:675",
20 | "name": "Rectangle 34",
21 | "type": "RECTANGLE",
22 | "cssStyles": {
23 | "width": "1580px",
24 | "height": "895px",
25 | "position": "absolute",
26 | "left": "0px",
27 | "top": "0px",
28 | "backgroundColor": "#FFFFFF",
29 | "borderRadius": "12px"
30 | }
31 | },
32 | {
33 | "id": "2:676",
34 | "name": "Group 1410104850",
35 | "type": "GROUP",
36 | "cssStyles": {
37 | "width": "350px",
38 | "height": "316px",
39 | "position": "absolute",
40 | "left": "615px",
41 | "top": "232px",
42 | "display": "flex",
43 | "flexDirection": "column",
44 | "gap": "32px",
45 | "justifyContent": "space-between",
46 | "alignItems": "center"
47 | },
48 | "children": [
49 | {
50 | "id": "2:689",
51 | "name": "Group 1410104849",
52 | "type": "GROUP",
53 | "cssStyles": {
54 | "width": "220px",
55 | "height": "138px",
56 | "position": "absolute",
57 | "left": "65px",
58 | "top": "0px"
59 | },
60 | "exportInfo": {
61 | "type": "IMAGE",
62 | "format": "PNG",
63 | "fileName": "group_1410104849.png"
64 | }
65 | },
66 | {
67 | "id": "2:677",
68 | "name": "Group 1410104480",
69 | "type": "GROUP",
70 | "cssStyles": {
71 | "width": "350px",
72 | "height": "148px",
73 | "position": "absolute",
74 | "left": "0px",
75 | "top": "168px",
76 | "display": "flex",
77 | "flexDirection": "column",
78 | "gap": "32px",
79 | "justifyContent": "space-between",
80 | "alignItems": "center"
81 | },
82 | "children": [
83 | {
84 | "id": "2:678",
85 | "name": "添加自定义关键词,当检测到关键词出现时您将接收警报",
86 | "type": "TEXT",
87 | "cssStyles": {
88 | "width": "350px",
89 | "height": "20px",
90 | "position": "absolute",
91 | "left": "0px",
92 | "top": "0px",
93 | "color": "#333333",
94 | "fontFamily": "PingFang SC",
95 | "fontSize": "14px",
96 | "fontWeight": 500,
97 | "textAlign": "center",
98 | "verticalAlign": "middle",
99 | "lineHeight": "20px"
100 | },
101 | "text": "添加自定义关键词,当检测到关键词出现时您将接收警报"
102 | },
103 | {
104 | "id": "2:679",
105 | "name": "Group 1410104479",
106 | "type": "GROUP",
107 | "cssStyles": {
108 | "width": "240px",
109 | "height": "98px",
110 | "position": "absolute",
111 | "left": "55px",
112 | "top": "50px",
113 | "display": "flex",
114 | "flexDirection": "column",
115 | "gap": "10px",
116 | "justifyContent": "space-between",
117 | "alignItems": "flex-start"
118 | },
119 | "children": [
120 | {
121 | "id": "2:680",
122 | "name": "Group 1410086131",
123 | "type": "GROUP",
124 | "cssStyles": {
125 | "width": "240px",
126 | "height": "44px",
127 | "position": "absolute",
128 | "left": "0px",
129 | "top": "0px"
130 | },
131 | "children": [
132 | {
133 | "id": "2:681",
134 | "name": "Rectangle 34625783",
135 | "type": "RECTANGLE",
136 | "cssStyles": {
137 | "width": "240px",
138 | "height": "44px",
139 | "position": "absolute",
140 | "left": "0px",
141 | "top": "0px",
142 | "backgroundColor": "#24C790",
143 | "borderRadius": "10px"
144 | }
145 | },
146 | {
147 | "id": "2:682",
148 | "name": "Group 1410104509",
149 | "type": "GROUP",
150 | "cssStyles": {
151 | "width": "115px",
152 | "height": "20px",
153 | "position": "absolute",
154 | "left": "63px",
155 | "top": "12px",
156 | "display": "flex",
157 | "gap": "10px",
158 | "justifyContent": "space-between",
159 | "alignItems": "flex-start"
160 | },
161 | "children": [
162 | {
163 | "id": "2:684",
164 | "name": "Frame",
165 | "type": "FRAME",
166 | "cssStyles": {
167 | "width": "20px",
168 | "height": "20px",
169 | "position": "absolute",
170 | "left": "0px",
171 | "top": "0px"
172 | },
173 | "exportInfo": {
174 | "type": "IMAGE",
175 | "format": "SVG",
176 | "fileName": "frame.svg"
177 | }
178 | },
179 | {
180 | "id": "2:683",
181 | "name": "AI生成关键词",
182 | "type": "TEXT",
183 | "cssStyles": {
184 | "width": "84px",
185 | "height": "16px",
186 | "position": "absolute",
187 | "left": "31px",
188 | "top": "2px",
189 | "color": "#FFFFFF",
190 | "fontFamily": "Roboto",
191 | "fontSize": "14px",
192 | "fontWeight": 700,
193 | "textAlign": "center",
194 | "verticalAlign": "middle",
195 | "lineHeight": "16px"
196 | },
197 | "text": "AI生成关键词"
198 | }
199 | ]
200 | }
201 | ]
202 | },
203 | {
204 | "id": "2:686",
205 | "name": "Group 1410104451",
206 | "type": "GROUP",
207 | "cssStyles": {
208 | "width": "240px",
209 | "height": "44px",
210 | "position": "absolute",
211 | "left": "0px",
212 | "top": "54px",
213 | "borderRadius": "10px"
214 | },
215 | "children": [
216 | {
217 | "id": "2:687",
218 | "name": "Rectangle 34625783",
219 | "type": "RECTANGLE",
220 | "cssStyles": {
221 | "width": "240px",
222 | "height": "44px",
223 | "position": "absolute",
224 | "left": "0px",
225 | "top": "0px",
226 | "backgroundColor": "#FFFFFF",
227 | "borderColor": "#C4C4C4",
228 | "borderStyle": "solid",
229 | "borderRadius": "10px"
230 | }
231 | },
232 | {
233 | "id": "2:688",
234 | "name": "添加自定义关键词",
235 | "type": "TEXT",
236 | "cssStyles": {
237 | "width": "146px",
238 | "height": "16px",
239 | "position": "absolute",
240 | "left": "48px",
241 | "top": "14px",
242 | "color": "#333333",
243 | "fontFamily": "Roboto",
244 | "fontSize": "14px",
245 | "fontWeight": 700,
246 | "textAlign": "center",
247 | "verticalAlign": "middle",
248 | "lineHeight": "16px"
249 | },
250 | "text": "添加自定义关键词"
251 | }
252 | ]
253 | }
254 | ]
255 | }
256 | ]
257 | }
258 | ]
259 | }
260 | ]
261 | }
262 | ]
263 | }
264 |
```
--------------------------------------------------------------------------------
/tests/unit/algorithms/icon-optimization.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Icon Detection Optimization Tests
3 | *
4 | * Verifies that the optimized collectNodeStats() function produces
5 | * identical results to the original individual functions.
6 | */
7 |
8 | import { describe, it, expect } from "vitest";
9 |
10 | // Test the internal implementation
11 | // We'll create mock nodes and verify the stats are correctly computed
12 |
13 | interface MockNode {
14 | id: string;
15 | name: string;
16 | type: string;
17 | children?: MockNode[];
18 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
19 | fills?: Array<{ type: string; visible?: boolean; imageRef?: string }>;
20 | effects?: Array<{ type: string; visible?: boolean }>;
21 | }
22 |
23 | // Helper functions to test (matching the original implementations)
24 | const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"];
25 | const MERGEABLE_TYPES = [
26 | "VECTOR",
27 | "RECTANGLE",
28 | "ELLIPSE",
29 | "LINE",
30 | "POLYGON",
31 | "STAR",
32 | "BOOLEAN_OPERATION",
33 | "REGULAR_POLYGON",
34 | ];
35 | const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"];
36 | const PNG_REQUIRED_EFFECTS = ["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"];
37 |
38 | function isContainerType(type: string): boolean {
39 | return CONTAINER_TYPES.includes(type);
40 | }
41 |
42 | function isMergeableType(type: string): boolean {
43 | return MERGEABLE_TYPES.includes(type);
44 | }
45 |
46 | function isExcludeType(type: string): boolean {
47 | return EXCLUDE_TYPES.includes(type);
48 | }
49 |
50 | function hasImageFill(node: MockNode): boolean {
51 | if (!node.fills) return false;
52 | return node.fills.some(
53 | (fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
54 | );
55 | }
56 |
57 | function hasComplexEffects(node: MockNode): boolean {
58 | if (!node.effects) return false;
59 | return node.effects.some(
60 | (effect) => effect.visible !== false && PNG_REQUIRED_EFFECTS.includes(effect.type),
61 | );
62 | }
63 |
64 | // Original functions (for comparison)
65 | function calculateDepthOriginal(node: MockNode, currentDepth: number = 0): number {
66 | if (!node.children || node.children.length === 0) {
67 | return currentDepth;
68 | }
69 | return Math.max(...node.children.map((child) => calculateDepthOriginal(child, currentDepth + 1)));
70 | }
71 |
72 | function countTotalChildrenOriginal(node: MockNode): number {
73 | if (!node.children || node.children.length === 0) {
74 | return 0;
75 | }
76 | return node.children.reduce((sum, child) => sum + 1 + countTotalChildrenOriginal(child), 0);
77 | }
78 |
79 | function hasExcludeTypeInTreeOriginal(node: MockNode): boolean {
80 | if (isExcludeType(node.type)) {
81 | return true;
82 | }
83 | if (node.children) {
84 | return node.children.some((child) => hasExcludeTypeInTreeOriginal(child));
85 | }
86 | return false;
87 | }
88 |
89 | function hasImageFillInTreeOriginal(node: MockNode): boolean {
90 | if (hasImageFill(node)) {
91 | return true;
92 | }
93 | if (node.children) {
94 | return node.children.some((child) => hasImageFillInTreeOriginal(child));
95 | }
96 | return false;
97 | }
98 |
99 | function hasComplexEffectsInTreeOriginal(node: MockNode): boolean {
100 | if (hasComplexEffects(node)) {
101 | return true;
102 | }
103 | if (node.children) {
104 | return node.children.some((child) => hasComplexEffectsInTreeOriginal(child));
105 | }
106 | return false;
107 | }
108 |
109 | function areAllLeavesMergeableOriginal(node: MockNode): boolean {
110 | if (!node.children || node.children.length === 0) {
111 | return isMergeableType(node.type);
112 | }
113 | if (isContainerType(node.type)) {
114 | return node.children.every((child) => areAllLeavesMergeableOriginal(child));
115 | }
116 | return isMergeableType(node.type);
117 | }
118 |
119 | // Optimized single-pass function
120 | interface NodeTreeStats {
121 | depth: number;
122 | totalChildren: number;
123 | hasExcludeType: boolean;
124 | hasImageFill: boolean;
125 | hasComplexEffects: boolean;
126 | allLeavesMergeable: boolean;
127 | mergeableRatio: number;
128 | }
129 |
130 | function collectNodeStats(node: MockNode): NodeTreeStats {
131 | if (!node.children || node.children.length === 0) {
132 | const isMergeable = isMergeableType(node.type);
133 | return {
134 | depth: 0,
135 | totalChildren: 0,
136 | hasExcludeType: isExcludeType(node.type),
137 | hasImageFill: hasImageFill(node),
138 | hasComplexEffects: hasComplexEffects(node),
139 | allLeavesMergeable: isMergeable,
140 | mergeableRatio: isMergeable ? 1 : 0,
141 | };
142 | }
143 |
144 | const childStats = node.children.map(collectNodeStats);
145 | const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
146 | const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
147 | const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
148 | const hasImageInChildren = childStats.some((s) => s.hasImageFill);
149 | const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
150 | const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
151 |
152 | const mergeableCount = node.children.filter(
153 | (child) => isMergeableType(child.type) || isContainerType(child.type),
154 | ).length;
155 | const mergeableRatio = mergeableCount / node.children.length;
156 |
157 | const allLeavesMergeable = isContainerType(node.type)
158 | ? allChildrenMergeable
159 | : isMergeableType(node.type);
160 |
161 | return {
162 | depth: maxChildDepth + 1,
163 | totalChildren: totalDescendants,
164 | hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
165 | hasImageFill: hasImageFill(node) || hasImageInChildren,
166 | hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
167 | allLeavesMergeable,
168 | mergeableRatio,
169 | };
170 | }
171 |
172 | describe("Icon Detection Optimization", () => {
173 | describe("collectNodeStats equivalence", () => {
174 | const testCases: { name: string; node: MockNode }[] = [
175 | {
176 | name: "simple leaf node",
177 | node: {
178 | id: "1",
179 | name: "Vector",
180 | type: "VECTOR",
181 | },
182 | },
183 | {
184 | name: "leaf node with excludable type",
185 | node: {
186 | id: "1",
187 | name: "Text",
188 | type: "TEXT",
189 | },
190 | },
191 | {
192 | name: "container with vector children",
193 | node: {
194 | id: "1",
195 | name: "Group",
196 | type: "GROUP",
197 | children: [
198 | { id: "2", name: "Vector1", type: "VECTOR" },
199 | { id: "3", name: "Vector2", type: "VECTOR" },
200 | ],
201 | },
202 | },
203 | {
204 | name: "nested container",
205 | node: {
206 | id: "1",
207 | name: "Frame",
208 | type: "FRAME",
209 | children: [
210 | {
211 | id: "2",
212 | name: "Group",
213 | type: "GROUP",
214 | children: [
215 | { id: "3", name: "Ellipse", type: "ELLIPSE" },
216 | { id: "4", name: "Rect", type: "RECTANGLE" },
217 | ],
218 | },
219 | ],
220 | },
221 | },
222 | {
223 | name: "container with text child",
224 | node: {
225 | id: "1",
226 | name: "Button",
227 | type: "FRAME",
228 | children: [
229 | { id: "2", name: "BG", type: "RECTANGLE" },
230 | { id: "3", name: "Label", type: "TEXT" },
231 | ],
232 | },
233 | },
234 | {
235 | name: "node with image fill",
236 | node: {
237 | id: "1",
238 | name: "Image",
239 | type: "RECTANGLE",
240 | fills: [{ type: "IMAGE", visible: true, imageRef: "abc123" }],
241 | },
242 | },
243 | {
244 | name: "node with complex effects",
245 | node: {
246 | id: "1",
247 | name: "Shadow Box",
248 | type: "FRAME",
249 | effects: [{ type: "DROP_SHADOW", visible: true }],
250 | children: [{ id: "2", name: "Content", type: "RECTANGLE" }],
251 | },
252 | },
253 | {
254 | name: "deeply nested structure",
255 | node: {
256 | id: "1",
257 | name: "Root",
258 | type: "FRAME",
259 | children: [
260 | {
261 | id: "2",
262 | name: "Level1",
263 | type: "GROUP",
264 | children: [
265 | {
266 | id: "3",
267 | name: "Level2",
268 | type: "GROUP",
269 | children: [
270 | {
271 | id: "4",
272 | name: "Level3",
273 | type: "GROUP",
274 | children: [{ id: "5", name: "Leaf", type: "VECTOR" }],
275 | },
276 | ],
277 | },
278 | ],
279 | },
280 | ],
281 | },
282 | },
283 | ];
284 |
285 | testCases.forEach(({ name, node }) => {
286 | it(`should produce equivalent results for: ${name}`, () => {
287 | const stats = collectNodeStats(node);
288 |
289 | // Compare with original functions
290 | expect(stats.depth).toBe(calculateDepthOriginal(node));
291 | expect(stats.totalChildren).toBe(countTotalChildrenOriginal(node));
292 | expect(stats.hasExcludeType).toBe(hasExcludeTypeInTreeOriginal(node));
293 | expect(stats.hasImageFill).toBe(hasImageFillInTreeOriginal(node));
294 | expect(stats.hasComplexEffects).toBe(hasComplexEffectsInTreeOriginal(node));
295 | expect(stats.allLeavesMergeable).toBe(areAllLeavesMergeableOriginal(node));
296 | });
297 | });
298 | });
299 |
300 | describe("edge cases", () => {
301 | it("should handle empty children array", () => {
302 | const node: MockNode = {
303 | id: "1",
304 | name: "Empty",
305 | type: "GROUP",
306 | children: [],
307 | };
308 |
309 | const stats = collectNodeStats(node);
310 | expect(stats.depth).toBe(0);
311 | expect(stats.totalChildren).toBe(0);
312 | });
313 |
314 | it("should handle invisible fills", () => {
315 | const node: MockNode = {
316 | id: "1",
317 | name: "Hidden Image",
318 | type: "RECTANGLE",
319 | fills: [{ type: "IMAGE", visible: false, imageRef: "abc123" }],
320 | };
321 |
322 | const stats = collectNodeStats(node);
323 | expect(stats.hasImageFill).toBe(false);
324 | });
325 |
326 | it("should handle invisible effects", () => {
327 | const node: MockNode = {
328 | id: "1",
329 | name: "Hidden Shadow",
330 | type: "RECTANGLE",
331 | effects: [{ type: "DROP_SHADOW", visible: false }],
332 | };
333 |
334 | const stats = collectNodeStats(node);
335 | expect(stats.hasComplexEffects).toBe(false);
336 | });
337 |
338 | it("should calculate correct mergeable ratio", () => {
339 | const node: MockNode = {
340 | id: "1",
341 | name: "Mixed",
342 | type: "FRAME",
343 | children: [
344 | { id: "2", name: "V1", type: "VECTOR" },
345 | { id: "3", name: "V2", type: "VECTOR" },
346 | { id: "4", name: "Unknown", type: "UNKNOWN_TYPE" },
347 | { id: "5", name: "G1", type: "GROUP" },
348 | ],
349 | };
350 |
351 | const stats = collectNodeStats(node);
352 | // 3 mergeable (2 VECTOR + 1 GROUP) out of 4
353 | expect(stats.mergeableRatio).toBe(0.75);
354 | });
355 | });
356 | });
357 |
```
--------------------------------------------------------------------------------
/tests/unit/services/cache.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Cache Manager Unit Tests
3 | *
4 | * Tests the multi-layer caching system for Figma API responses and images.
5 | */
6 |
7 | import { describe, it, expect, beforeEach, afterEach } from "vitest";
8 | import * as fs from "fs";
9 | import * as path from "path";
10 | import * as os from "os";
11 | import { CacheManager } from "~/services/cache/index.js";
12 |
13 | describe("CacheManager", () => {
14 | let cacheManager: CacheManager;
15 | let testCacheDir: string;
16 |
17 | beforeEach(() => {
18 | // Create a unique temporary directory for each test
19 | testCacheDir = path.join(os.tmpdir(), `figma-cache-test-${Date.now()}`);
20 | cacheManager = new CacheManager({
21 | enabled: true,
22 | memory: {
23 | maxNodeItems: 100,
24 | maxImageItems: 50,
25 | nodeTTL: 1000, // 1 second TTL for testing
26 | imageTTL: 1000,
27 | },
28 | disk: {
29 | cacheDir: testCacheDir,
30 | maxSize: 100 * 1024 * 1024,
31 | ttl: 1000, // 1 second TTL for testing
32 | },
33 | });
34 | });
35 |
36 | afterEach(() => {
37 | // Clean up test cache directory
38 | if (fs.existsSync(testCacheDir)) {
39 | fs.rmSync(testCacheDir, { recursive: true, force: true });
40 | }
41 | });
42 |
43 | describe("Configuration", () => {
44 | it("should create cache directories on initialization", () => {
45 | expect(fs.existsSync(testCacheDir)).toBe(true);
46 | expect(fs.existsSync(path.join(testCacheDir, "data"))).toBe(true);
47 | expect(fs.existsSync(path.join(testCacheDir, "images"))).toBe(true);
48 | expect(fs.existsSync(path.join(testCacheDir, "metadata"))).toBe(true);
49 | });
50 |
51 | it("should not create directories when disabled", () => {
52 | const disabledCacheDir = path.join(os.tmpdir(), `figma-cache-disabled-${Date.now()}`);
53 | new CacheManager({
54 | enabled: false,
55 | disk: {
56 | cacheDir: disabledCacheDir,
57 | maxSize: 100 * 1024 * 1024,
58 | ttl: 1000,
59 | },
60 | });
61 |
62 | expect(fs.existsSync(disabledCacheDir)).toBe(false);
63 | });
64 |
65 | it("should return correct cache stats", async () => {
66 | const stats = await cacheManager.getStats();
67 |
68 | expect(stats.enabled).toBe(true);
69 | expect(stats.memory.size).toBe(0);
70 | expect(stats.disk.nodeFileCount).toBe(0);
71 | expect(stats.disk.imageFileCount).toBe(0);
72 | expect(stats.disk.totalSize).toBe(0);
73 | });
74 |
75 | it("should return cache directory", () => {
76 | expect(cacheManager.getCacheDir()).toBe(testCacheDir);
77 | });
78 |
79 | it("should report enabled status", () => {
80 | expect(cacheManager.isEnabled()).toBe(true);
81 | });
82 | });
83 |
84 | describe("Node Data Caching", () => {
85 | const testData = { id: "123", name: "Test Node", type: "FRAME" };
86 | const fileKey = "test-file-key";
87 |
88 | it("should cache and retrieve node data", async () => {
89 | await cacheManager.setNodeData(testData, fileKey);
90 | const cached = await cacheManager.getNodeData(fileKey);
91 |
92 | expect(cached).toEqual(testData);
93 | });
94 |
95 | it("should cache with nodeId parameter", async () => {
96 | const nodeId = "node-456";
97 | await cacheManager.setNodeData(testData, fileKey, nodeId);
98 | const cached = await cacheManager.getNodeData(fileKey, nodeId);
99 |
100 | expect(cached).toEqual(testData);
101 | });
102 |
103 | it("should cache with depth parameter", async () => {
104 | const nodeId = "node-789";
105 | const depth = 3;
106 | await cacheManager.setNodeData(testData, fileKey, nodeId, depth);
107 | const cached = await cacheManager.getNodeData(fileKey, nodeId, depth);
108 |
109 | expect(cached).toEqual(testData);
110 | });
111 |
112 | it("should return null for non-existent cache", async () => {
113 | const cached = await cacheManager.getNodeData("non-existent-key");
114 |
115 | expect(cached).toBeNull();
116 | });
117 |
118 | it("should return null for expired cache", async () => {
119 | await cacheManager.setNodeData(testData, fileKey);
120 |
121 | // Wait for cache to expire (TTL is 1 second)
122 | await new Promise((resolve) => setTimeout(resolve, 1100));
123 |
124 | const cached = await cacheManager.getNodeData(fileKey);
125 | expect(cached).toBeNull();
126 | });
127 |
128 | it("should update cache stats after caching data", async () => {
129 | await cacheManager.setNodeData(testData, fileKey);
130 | const stats = await cacheManager.getStats();
131 |
132 | expect(stats.memory.size).toBe(1);
133 | expect(stats.disk.nodeFileCount).toBe(1);
134 | expect(stats.disk.totalSize).toBeGreaterThan(0);
135 | });
136 |
137 | it("should check if node data exists", async () => {
138 | expect(await cacheManager.hasNodeData(fileKey)).toBe(false);
139 | await cacheManager.setNodeData(testData, fileKey);
140 | expect(await cacheManager.hasNodeData(fileKey)).toBe(true);
141 | });
142 | });
143 |
144 | describe("Image Caching", () => {
145 | const fileKey = "test-file";
146 | const nodeId = "image-node";
147 | const format = "png";
148 | let testImagePath: string;
149 |
150 | beforeEach(() => {
151 | // Create a test image file
152 | testImagePath = path.join(os.tmpdir(), `test-image-${Date.now()}.png`);
153 | fs.writeFileSync(testImagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
154 | });
155 |
156 | afterEach(() => {
157 | if (fs.existsSync(testImagePath)) {
158 | fs.unlinkSync(testImagePath);
159 | }
160 | });
161 |
162 | it("should return null for uncached image", async () => {
163 | const result = await cacheManager.hasImage(fileKey, nodeId, format);
164 | expect(result).toBeNull();
165 | });
166 |
167 | it("should cache and find image", async () => {
168 | await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
169 | const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
170 |
171 | expect(cachedPath).not.toBeNull();
172 | expect(fs.existsSync(cachedPath!)).toBe(true);
173 | });
174 |
175 | it("should copy image from cache to target path", async () => {
176 | await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
177 |
178 | const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
179 | const success = await cacheManager.copyImageFromCache(fileKey, nodeId, format, targetPath);
180 |
181 | expect(success).toBe(true);
182 | expect(fs.existsSync(targetPath)).toBe(true);
183 |
184 | // Clean up
185 | fs.unlinkSync(targetPath);
186 | });
187 |
188 | it("should return false when copying non-existent image", async () => {
189 | const targetPath = path.join(os.tmpdir(), `target-${Date.now()}.png`);
190 | const success = await cacheManager.copyImageFromCache(
191 | "non-existent",
192 | "non-existent",
193 | format,
194 | targetPath,
195 | );
196 |
197 | expect(success).toBe(false);
198 | });
199 |
200 | it("should update cache stats after caching image", async () => {
201 | await cacheManager.cacheImage(testImagePath, fileKey, nodeId, format);
202 | const stats = await cacheManager.getStats();
203 |
204 | expect(stats.disk.imageFileCount).toBe(1);
205 | });
206 | });
207 |
208 | describe("Cache Cleanup", () => {
209 | it("should clean expired cache entries", async () => {
210 | const testData = { id: "test" };
211 | await cacheManager.setNodeData(testData, "file-1");
212 |
213 | // Wait for cache to expire
214 | await new Promise((resolve) => setTimeout(resolve, 1100));
215 |
216 | const result = await cacheManager.cleanExpired();
217 | expect(result.disk).toBeGreaterThanOrEqual(1);
218 |
219 | const stats = await cacheManager.getStats();
220 | expect(stats.disk.nodeFileCount).toBe(0);
221 | });
222 |
223 | it("should clear all cache", async () => {
224 | await cacheManager.setNodeData({ id: "1" }, "file-1");
225 | await cacheManager.setNodeData({ id: "2" }, "file-2");
226 |
227 | await cacheManager.clearAll();
228 |
229 | const stats = await cacheManager.getStats();
230 | expect(stats.memory.size).toBe(0);
231 | expect(stats.disk.nodeFileCount).toBe(0);
232 | expect(stats.disk.imageFileCount).toBe(0);
233 | });
234 | });
235 |
236 | describe("Cache Invalidation", () => {
237 | it("should invalidate all entries for a file", async () => {
238 | const fileKey = "test-file";
239 | await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
240 | await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
241 | await cacheManager.setNodeData({ id: "3" }, "other-file", "node-3");
242 |
243 | const result = await cacheManager.invalidateFile(fileKey);
244 |
245 | expect(result.memory).toBe(2);
246 | expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
247 | expect(await cacheManager.getNodeData(fileKey, "node-2")).toBeNull();
248 | // Other file should still be cached
249 | expect(await cacheManager.getNodeData("other-file", "node-3")).not.toBeNull();
250 | });
251 |
252 | it("should invalidate a specific node", async () => {
253 | const fileKey = "test-file";
254 | await cacheManager.setNodeData({ id: "1" }, fileKey, "node-1");
255 | await cacheManager.setNodeData({ id: "2" }, fileKey, "node-2");
256 |
257 | const result = await cacheManager.invalidateNode(fileKey, "node-1");
258 |
259 | expect(result.memory).toBe(1);
260 | expect(await cacheManager.getNodeData(fileKey, "node-1")).toBeNull();
261 | expect(await cacheManager.getNodeData(fileKey, "node-2")).not.toBeNull();
262 | });
263 | });
264 |
265 | describe("Disabled Cache", () => {
266 | let disabledCacheManager: CacheManager;
267 |
268 | beforeEach(() => {
269 | disabledCacheManager = new CacheManager({ enabled: false });
270 | });
271 |
272 | it("should return null for getNodeData when disabled", async () => {
273 | const result = await disabledCacheManager.getNodeData("any-key");
274 | expect(result).toBeNull();
275 | });
276 |
277 | it("should do nothing for setNodeData when disabled", async () => {
278 | await disabledCacheManager.setNodeData({ id: "test" }, "file-key");
279 | const result = await disabledCacheManager.getNodeData("file-key");
280 | expect(result).toBeNull();
281 | });
282 |
283 | it("should return null for hasImage when disabled", async () => {
284 | const result = await disabledCacheManager.hasImage("file", "node", "png");
285 | expect(result).toBeNull();
286 | });
287 |
288 | it("should return source path for cacheImage when disabled", async () => {
289 | const sourcePath = "/path/to/image.png";
290 | const result = await disabledCacheManager.cacheImage(sourcePath, "file", "node", "png");
291 | expect(result).toBe(sourcePath);
292 | });
293 |
294 | it("should return zero for cleanExpired when disabled", async () => {
295 | const result = await disabledCacheManager.cleanExpired();
296 | expect(result.memory).toBe(0);
297 | expect(result.disk).toBe(0);
298 | });
299 |
300 | it("should report disabled in stats", async () => {
301 | const stats = await disabledCacheManager.getStats();
302 | expect(stats.enabled).toBe(false);
303 | });
304 |
305 | it("should report disabled status", () => {
306 | expect(disabledCacheManager.isEnabled()).toBe(false);
307 | });
308 | });
309 |
310 | describe("Memory Cache (L1)", () => {
311 | it("should serve from memory cache on second read", async () => {
312 | const testData = { id: "memory-test" };
313 | const fileKey = "memory-file";
314 |
315 | await cacheManager.setNodeData(testData, fileKey);
316 |
317 | // First read - populates memory from disk if needed
318 | const first = await cacheManager.getNodeData(fileKey);
319 | expect(first).toEqual(testData);
320 |
321 | // Second read should hit memory cache
322 | const second = await cacheManager.getNodeData(fileKey);
323 | expect(second).toEqual(testData);
324 |
325 | const stats = await cacheManager.getStats();
326 | expect(stats.memory.hits).toBeGreaterThan(0);
327 | });
328 |
329 | it("should track cache statistics", async () => {
330 | // Initial stats
331 | let stats = await cacheManager.getStats();
332 | expect(stats.memory.hits).toBe(0);
333 | expect(stats.memory.misses).toBe(0);
334 |
335 | // Miss
336 | await cacheManager.getNodeData("non-existent");
337 | stats = await cacheManager.getStats();
338 | expect(stats.memory.misses).toBe(1);
339 |
340 | // Set and hit
341 | await cacheManager.setNodeData({ test: 1 }, "key");
342 | await cacheManager.getNodeData("key");
343 | stats = await cacheManager.getStats();
344 | expect(stats.memory.hits).toBe(1);
345 | });
346 |
347 | it("should reset statistics", async () => {
348 | await cacheManager.setNodeData({ test: 1 }, "key");
349 | await cacheManager.getNodeData("key");
350 | await cacheManager.getNodeData("non-existent");
351 |
352 | cacheManager.resetStats();
353 | const stats = await cacheManager.getStats();
354 | expect(stats.memory.hits).toBe(0);
355 | expect(stats.memory.misses).toBe(0);
356 | });
357 | });
358 | });
359 |
```
--------------------------------------------------------------------------------
/tests/integration/output-quality.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Output Quality Validation Tests
3 | *
4 | * Tests the quality of optimized output for redundancy, consistency, and correctness.
5 | * Converted from scripts/analyze-optimized-output.ts
6 | */
7 |
8 | import { describe, it, expect, beforeAll } from "vitest";
9 | import * as fs from "fs";
10 | import * as path from "path";
11 | import { fileURLToPath } from "url";
12 | import { parseFigmaResponse } from "~/core/parser.js";
13 | import type { SimplifiedNode } from "~/types/index.js";
14 |
15 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
16 | const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
17 |
18 | // Test file configurations
19 | const TEST_FILES = [
20 | { name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
21 | { name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
22 | ];
23 |
24 | // Quality analysis interfaces
25 | interface QualityAnalysis {
26 | totalNodes: number;
27 | nodesByType: Record<string, number>;
28 | layoutStats: {
29 | flex: number;
30 | grid: number;
31 | absolute: number;
32 | none: number;
33 | };
34 | cssPropertyUsage: Record<string, number>;
35 | redundantPatterns: RedundantPattern[];
36 | emptyOrDefaultValues: EmptyValue[];
37 | issues: QualityIssue[];
38 | }
39 |
40 | interface RedundantPattern {
41 | nodeName: string;
42 | pattern: string;
43 | details: string;
44 | }
45 |
46 | interface EmptyValue {
47 | nodeName: string;
48 | property: string;
49 | value: string;
50 | }
51 |
52 | interface QualityIssue {
53 | nodeName: string;
54 | nodeType: string;
55 | issue: string;
56 | severity: "warning" | "error";
57 | }
58 |
59 | // Helper: Analyze a node recursively
60 | function analyzeNode(node: SimplifiedNode, result: QualityAnalysis, parentLayout?: string): void {
61 | result.totalNodes++;
62 |
63 | // Count node types
64 | result.nodesByType[node.type] = (result.nodesByType[node.type] || 0) + 1;
65 |
66 | // Count layout types
67 | const display = node.cssStyles?.display;
68 | if (display === "flex") {
69 | result.layoutStats.flex++;
70 | } else if (display === "grid") {
71 | result.layoutStats.grid++;
72 | } else if (node.cssStyles?.position === "absolute") {
73 | result.layoutStats.absolute++;
74 | } else {
75 | result.layoutStats.none++;
76 | }
77 |
78 | // Analyze CSS properties
79 | if (node.cssStyles) {
80 | for (const [key, value] of Object.entries(node.cssStyles)) {
81 | if (value !== undefined && value !== null && value !== "") {
82 | result.cssPropertyUsage[key] = (result.cssPropertyUsage[key] || 0) + 1;
83 | }
84 |
85 | // Check for empty or default values
86 | if (value === "" || value === "0" || value === "0px" || value === "none") {
87 | result.emptyOrDefaultValues.push({
88 | nodeName: node.name,
89 | property: key,
90 | value: String(value),
91 | });
92 | }
93 |
94 | // Check for redundant patterns
95 | // 1. position: absolute inside flex/grid parent
96 | if (key === "position" && value === "absolute" && parentLayout) {
97 | if (parentLayout === "flex" || parentLayout === "grid") {
98 | result.redundantPatterns.push({
99 | nodeName: node.name,
100 | pattern: "absolute-in-layout",
101 | details: `position:absolute inside ${parentLayout} parent`,
102 | });
103 | }
104 | }
105 |
106 | // 2. width with flex property (potential conflict)
107 | if (key === "width" && node.cssStyles?.flex) {
108 | result.redundantPatterns.push({
109 | nodeName: node.name,
110 | pattern: "width-with-flex",
111 | details: "width specified with flex property",
112 | });
113 | }
114 | }
115 | }
116 |
117 | // Check for quality issues
118 | // 1. TEXT node with layout properties
119 | if (node.type === "TEXT" && (display === "flex" || display === "grid")) {
120 | result.issues.push({
121 | nodeName: node.name,
122 | nodeType: node.type,
123 | issue: `TEXT node with ${display} layout (unnecessary)`,
124 | severity: "warning",
125 | });
126 | }
127 |
128 | // 2. Empty children array
129 | if (node.children && node.children.length === 0) {
130 | result.issues.push({
131 | nodeName: node.name,
132 | nodeType: node.type,
133 | issue: "Empty children array (should be removed)",
134 | severity: "warning",
135 | });
136 | }
137 |
138 | // 3. VECTOR/ELLIPSE without exportInfo
139 | if ((node.type === "VECTOR" || node.type === "ELLIPSE") && !node.exportInfo) {
140 | result.issues.push({
141 | nodeName: node.name,
142 | nodeType: node.type,
143 | issue: `${node.type} without exportInfo (image not exported)`,
144 | severity: "warning",
145 | });
146 | }
147 |
148 | // Recurse into children
149 | if (node.children) {
150 | const currentLayout =
151 | display || (node.cssStyles?.position === "absolute" ? "absolute" : undefined);
152 | for (const child of node.children) {
153 | analyzeNode(child, result, currentLayout);
154 | }
155 | }
156 | }
157 |
158 | // Helper: Analyze a parsed result
159 | function analyzeOutput(result: ReturnType<typeof parseFigmaResponse>): QualityAnalysis {
160 | const analysis: QualityAnalysis = {
161 | totalNodes: 0,
162 | nodesByType: {},
163 | layoutStats: { flex: 0, grid: 0, absolute: 0, none: 0 },
164 | cssPropertyUsage: {},
165 | redundantPatterns: [],
166 | emptyOrDefaultValues: [],
167 | issues: [],
168 | };
169 |
170 | for (const node of result.nodes) {
171 | analyzeNode(node, analysis);
172 | }
173 |
174 | return analysis;
175 | }
176 |
177 | // Helper: Load and parse fixture
178 | function loadAndParse(name: string): ReturnType<typeof parseFigmaResponse> {
179 | const filePath = path.join(fixturesDir, `${name}.json`);
180 | const rawData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
181 | return parseFigmaResponse(rawData);
182 | }
183 |
184 | describe("Output Quality Validation", () => {
185 | TEST_FILES.forEach(({ name, desc }) => {
186 | describe(`${name} (${desc})`, () => {
187 | let result: ReturnType<typeof parseFigmaResponse>;
188 | let analysis: QualityAnalysis;
189 |
190 | beforeAll(() => {
191 | const filePath = path.join(fixturesDir, `${name}.json`);
192 | if (!fs.existsSync(filePath)) {
193 | throw new Error(`Test fixture not found: ${name}.json`);
194 | }
195 |
196 | result = loadAndParse(name);
197 | analysis = analyzeOutput(result);
198 | });
199 |
200 | describe("Node Structure", () => {
201 | it("should have non-zero node count", () => {
202 | expect(analysis.totalNodes).toBeGreaterThan(0);
203 | });
204 |
205 | it("should have diverse node types", () => {
206 | const typeCount = Object.keys(analysis.nodesByType).length;
207 | expect(typeCount).toBeGreaterThan(1);
208 | });
209 |
210 | it("should have reasonable node type distribution", () => {
211 | // No single type should dominate excessively (>90%)
212 | const maxTypeCount = Math.max(...Object.values(analysis.nodesByType));
213 | const dominanceRatio = maxTypeCount / analysis.totalNodes;
214 | expect(dominanceRatio).toBeLessThan(0.9);
215 | });
216 | });
217 |
218 | describe("Layout Quality", () => {
219 | it("should use semantic layouts (flex/grid)", () => {
220 | const semanticLayouts = analysis.layoutStats.flex + analysis.layoutStats.grid;
221 | expect(semanticLayouts).toBeGreaterThan(0);
222 | });
223 |
224 | it("should have reasonable absolute positioning ratio", () => {
225 | const absoluteRatio = analysis.layoutStats.absolute / analysis.totalNodes;
226 | // Warning if >80% absolute (but not a hard failure for all fixtures)
227 | expect(absoluteRatio).toBeLessThan(0.95);
228 | });
229 | });
230 |
231 | describe("CSS Property Quality", () => {
232 | it("should have essential CSS properties", () => {
233 | // Width and height should be commonly used
234 | const hasWidth = (analysis.cssPropertyUsage["width"] || 0) > 0;
235 | const hasHeight = (analysis.cssPropertyUsage["height"] || 0) > 0;
236 | expect(hasWidth || hasHeight).toBe(true);
237 | });
238 |
239 | it("should not have excessive empty or default values", () => {
240 | // Empty values should be less than 50% of total nodes
241 | // Note: Some default values like "0px" may be intentional for clarity
242 | const emptyRatio = analysis.emptyOrDefaultValues.length / analysis.totalNodes;
243 | expect(emptyRatio).toBeLessThan(0.5);
244 | });
245 |
246 | it("should have consistent property usage", () => {
247 | // If display is used, it should be meaningful
248 | const displayCount = analysis.cssPropertyUsage["display"] || 0;
249 | if (displayCount > 0) {
250 | // Display should be on containers, not every node
251 | expect(displayCount).toBeLessThan(analysis.totalNodes);
252 | }
253 | });
254 | });
255 |
256 | describe("Redundancy Check", () => {
257 | it("should minimize position:absolute inside flex/grid children", () => {
258 | const absoluteInLayout = analysis.redundantPatterns.filter(
259 | (p) => p.pattern === "absolute-in-layout",
260 | );
261 | // Allow some absolute positioning for:
262 | // - Overlapping elements that need stacking
263 | // - Non-homogeneous elements in grid containers (e.g., tabs, dividers)
264 | // These are intentionally kept absolute to preserve their original position
265 | const ratio = absoluteInLayout.length / analysis.totalNodes;
266 | expect(ratio).toBeLessThan(0.1); // Allow up to 10%
267 | });
268 |
269 | it("should not have conflicting width and flex properties", () => {
270 | const widthWithFlex = analysis.redundantPatterns.filter(
271 | (p) => p.pattern === "width-with-flex",
272 | );
273 | // Warning level - not necessarily wrong but worth noting
274 | // Allow up to 5% of nodes to have this pattern
275 | const ratio = widthWithFlex.length / analysis.totalNodes;
276 | expect(ratio).toBeLessThan(0.05);
277 | });
278 | });
279 |
280 | describe("Quality Issues", () => {
281 | it("should not have TEXT nodes with layout properties", () => {
282 | const textWithLayout = analysis.issues.filter(
283 | (i) => i.nodeType === "TEXT" && i.issue.includes("layout"),
284 | );
285 | expect(textWithLayout.length).toBe(0);
286 | });
287 |
288 | it("should not have empty children arrays", () => {
289 | const emptyChildren = analysis.issues.filter((i) => i.issue.includes("Empty children"));
290 | expect(emptyChildren.length).toBe(0);
291 | });
292 |
293 | it("should have exportInfo for vector graphics", () => {
294 | const vectorsWithoutExport = analysis.issues.filter((i) =>
295 | i.issue.includes("without exportInfo"),
296 | );
297 | // Allow some vectors without export (decorative elements)
298 | const vectorCount =
299 | (analysis.nodesByType["VECTOR"] || 0) + (analysis.nodesByType["ELLIPSE"] || 0);
300 | if (vectorCount > 0) {
301 | const missingExportRatio = vectorsWithoutExport.length / vectorCount;
302 | expect(missingExportRatio).toBeLessThan(0.5);
303 | }
304 | });
305 | });
306 |
307 | describe("Output Statistics", () => {
308 | it("should produce consistent layout statistics", () => {
309 | // Snapshot the statistics for regression detection
310 | expect({
311 | totalNodes: analysis.totalNodes,
312 | flexCount: analysis.layoutStats.flex,
313 | gridCount: analysis.layoutStats.grid,
314 | absoluteCount: analysis.layoutStats.absolute,
315 | issueCount: analysis.issues.length,
316 | redundantCount: analysis.redundantPatterns.length,
317 | }).toMatchSnapshot();
318 | });
319 | });
320 | });
321 | });
322 |
323 | describe("Cross-fixture Consistency", () => {
324 | let analyses: Map<string, QualityAnalysis>;
325 |
326 | beforeAll(() => {
327 | analyses = new Map();
328 | TEST_FILES.forEach(({ name }) => {
329 | const filePath = path.join(fixturesDir, `${name}.json`);
330 | if (fs.existsSync(filePath)) {
331 | const result = loadAndParse(name);
332 | analyses.set(name, analyzeOutput(result));
333 | }
334 | });
335 | });
336 |
337 | it("should use consistent CSS properties across fixtures", () => {
338 | const allProperties = new Set<string>();
339 | analyses.forEach((analysis) => {
340 | Object.keys(analysis.cssPropertyUsage).forEach((prop) => {
341 | allProperties.add(prop);
342 | });
343 | });
344 |
345 | // Essential properties should appear in all fixtures
346 | const essentialProps = ["width", "height"];
347 | essentialProps.forEach((prop) => {
348 | let count = 0;
349 | analyses.forEach((analysis) => {
350 | if (analysis.cssPropertyUsage[prop]) count++;
351 | });
352 | expect(count).toBeGreaterThan(0);
353 | });
354 | });
355 |
356 | it("should have similar quality metrics across fixtures", () => {
357 | const qualityScores: number[] = [];
358 |
359 | analyses.forEach((analysis) => {
360 | // Quality score: higher is better
361 | const semanticRatio =
362 | (analysis.layoutStats.flex + analysis.layoutStats.grid) / analysis.totalNodes;
363 | const issueRatio = analysis.issues.length / analysis.totalNodes;
364 | const score = semanticRatio * 100 - issueRatio * 50;
365 | qualityScores.push(score);
366 | });
367 |
368 | // All fixtures should have non-negative quality scores
369 | qualityScores.forEach((score) => {
370 | expect(score).toBeGreaterThanOrEqual(-10);
371 | });
372 | });
373 | });
374 | });
375 |
```
--------------------------------------------------------------------------------
/src/algorithms/layout/spatial.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Spatial Projection Analyzer
3 | *
4 | * Analyzes spatial relationships between nodes using 2D projection techniques.
5 | * Used to detect layout patterns, grouping, and containment relationships.
6 | *
7 | * @module algorithms/layout/spatial
8 | */
9 |
10 | import type { SimplifiedNode } from "~/types/index.js";
11 |
12 | // ==================== Type Definitions ====================
13 |
14 | /**
15 | * Rectangle representing a node's bounding box
16 | */
17 | export interface Rect {
18 | left: number;
19 | top: number;
20 | width: number;
21 | height: number;
22 | }
23 |
24 | /**
25 | * Spatial relationship between two nodes
26 | */
27 | export enum NodeRelationship {
28 | /** One node completely contains another */
29 | CONTAINS = "contains",
30 | /** Two nodes partially overlap */
31 | INTERSECTS = "intersects",
32 | /** Two nodes do not overlap */
33 | SEPARATE = "separate",
34 | }
35 |
36 | /**
37 | * Projection line for spatial division
38 | */
39 | export interface ProjectionLine {
40 | /** Position of the line */
41 | position: number;
42 | /** Direction: 'horizontal' or 'vertical' */
43 | direction: "horizontal" | "vertical";
44 | /** Indices of nodes intersecting this line */
45 | nodeIndices: number[];
46 | }
47 |
48 | // ==================== Helper Functions ====================
49 |
50 | /**
51 | * Safely parse CSS numeric value
52 | * @param value - CSS value string
53 | * @param defaultValue - Default value if parsing fails
54 | */
55 | function safeParseFloat(value: string | undefined | null, defaultValue: number = 0): number {
56 | if (!value) return defaultValue;
57 | const parsed = parseFloat(value);
58 | return isNaN(parsed) ? defaultValue : parsed;
59 | }
60 |
61 | /**
62 | * Get node position from absolute coordinates or CSS styles
63 | */
64 | function getNodePosition(node: SimplifiedNode): { left: number; top: number } {
65 | let left = 0;
66 | let top = 0;
67 |
68 | // Prefer absolute coordinates
69 | if (typeof node._absoluteX === "number") {
70 | left = node._absoluteX;
71 | } else if (node.cssStyles?.left) {
72 | left = safeParseFloat(node.cssStyles.left as string);
73 | }
74 |
75 | if (typeof node._absoluteY === "number") {
76 | top = node._absoluteY;
77 | } else if (node.cssStyles?.top) {
78 | top = safeParseFloat(node.cssStyles.top as string);
79 | }
80 |
81 | return { left, top };
82 | }
83 |
84 | // ==================== Rectangle Utilities ====================
85 |
86 | /**
87 | * Utility class for rectangle operations
88 | */
89 | export class RectUtils {
90 | /**
91 | * Create Rect from SimplifiedNode
92 | */
93 | static fromNode(node: SimplifiedNode): Rect | null {
94 | if (!node.cssStyles || !node.cssStyles.width || !node.cssStyles.height) {
95 | return null;
96 | }
97 |
98 | const width = safeParseFloat(node.cssStyles.width as string, 0);
99 | const height = safeParseFloat(node.cssStyles.height as string, 0);
100 |
101 | // Invalid rectangle if dimensions are zero
102 | if (width <= 0 || height <= 0) {
103 | return null;
104 | }
105 |
106 | const { left, top } = getNodePosition(node);
107 | return { left, top, width, height };
108 | }
109 |
110 | /**
111 | * Check if rectangle A contains rectangle B
112 | */
113 | static contains(a: Rect, b: Rect): boolean {
114 | return (
115 | a.left <= b.left &&
116 | a.top <= b.top &&
117 | a.left + a.width >= b.left + b.width &&
118 | a.top + a.height >= b.top + b.height
119 | );
120 | }
121 |
122 | /**
123 | * Check if two rectangles intersect
124 | */
125 | static intersects(a: Rect, b: Rect): boolean {
126 | return !(
127 | a.left + a.width <= b.left ||
128 | b.left + b.width <= a.left ||
129 | a.top + a.height <= b.top ||
130 | b.top + b.height <= a.top
131 | );
132 | }
133 |
134 | /**
135 | * Calculate intersection of two rectangles
136 | */
137 | static intersection(a: Rect, b: Rect): Rect | null {
138 | if (!RectUtils.intersects(a, b)) {
139 | return null;
140 | }
141 |
142 | const left = Math.max(a.left, b.left);
143 | const top = Math.max(a.top, b.top);
144 | const right = Math.min(a.left + a.width, b.left + b.width);
145 | const bottom = Math.min(a.top + a.height, b.top + b.height);
146 |
147 | return {
148 | left,
149 | top,
150 | width: right - left,
151 | height: bottom - top,
152 | };
153 | }
154 |
155 | /**
156 | * Analyze spatial relationship between two rectangles
157 | */
158 | static analyzeRelationship(a: Rect, b: Rect): NodeRelationship {
159 | if (RectUtils.contains(a, b)) {
160 | return NodeRelationship.CONTAINS;
161 | } else if (RectUtils.contains(b, a)) {
162 | return NodeRelationship.CONTAINS;
163 | } else if (RectUtils.intersects(a, b)) {
164 | return NodeRelationship.INTERSECTS;
165 | } else {
166 | return NodeRelationship.SEPARATE;
167 | }
168 | }
169 | }
170 |
171 | // ==================== Spatial Projection Analyzer ====================
172 |
173 | /**
174 | * 2D Spatial Projection Analyzer
175 | *
176 | * Uses projection lines to analyze spatial relationships
177 | * and group nodes by rows and columns.
178 | */
179 | export class SpatialProjectionAnalyzer {
180 | /**
181 | * Convert SimplifiedNode array to Rect array
182 | */
183 | static nodesToRects(nodes: SimplifiedNode[]): Rect[] {
184 | return nodes
185 | .map((node) => RectUtils.fromNode(node))
186 | .filter((rect): rect is Rect => rect !== null);
187 | }
188 |
189 | /**
190 | * Generate horizontal projection lines (for row detection)
191 | * @param rects - Rectangle array
192 | * @param tolerance - Coordinate tolerance in pixels
193 | */
194 | static generateHorizontalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
195 | if (rects.length === 0) return [];
196 |
197 | // Collect all top and bottom Y coordinates
198 | const yCoordinates: number[] = [];
199 | rects.forEach((rect) => {
200 | yCoordinates.push(rect.top);
201 | yCoordinates.push(rect.top + rect.height);
202 | });
203 |
204 | // Sort and filter nearby coordinates
205 | yCoordinates.sort((a, b) => a - b);
206 | const uniqueYCoordinates: number[] = [];
207 |
208 | for (let i = 0; i < yCoordinates.length; i++) {
209 | if (i === 0 || Math.abs(yCoordinates[i] - yCoordinates[i - 1]) > tolerance) {
210 | uniqueYCoordinates.push(yCoordinates[i]);
211 | }
212 | }
213 |
214 | // Create projection line for each Y coordinate
215 | return uniqueYCoordinates.map((y) => {
216 | const line: ProjectionLine = {
217 | position: y,
218 | direction: "horizontal",
219 | nodeIndices: [],
220 | };
221 |
222 | // Find all nodes intersecting this line
223 | for (let i = 0; i < rects.length; i++) {
224 | const rect = rects[i];
225 | if (y >= rect.top && y <= rect.top + rect.height) {
226 | line.nodeIndices.push(i);
227 | }
228 | }
229 |
230 | return line;
231 | });
232 | }
233 |
234 | /**
235 | * Generate vertical projection lines (for column detection)
236 | * @param rects - Rectangle array
237 | * @param tolerance - Coordinate tolerance in pixels
238 | */
239 | static generateVerticalProjectionLines(rects: Rect[], tolerance: number = 1): ProjectionLine[] {
240 | if (rects.length === 0) return [];
241 |
242 | // Collect all left and right X coordinates
243 | const xCoordinates: number[] = [];
244 | rects.forEach((rect) => {
245 | xCoordinates.push(rect.left);
246 | xCoordinates.push(rect.left + rect.width);
247 | });
248 |
249 | // Sort and filter nearby coordinates
250 | xCoordinates.sort((a, b) => a - b);
251 | const uniqueXCoordinates: number[] = [];
252 |
253 | for (let i = 0; i < xCoordinates.length; i++) {
254 | if (i === 0 || Math.abs(xCoordinates[i] - xCoordinates[i - 1]) > tolerance) {
255 | uniqueXCoordinates.push(xCoordinates[i]);
256 | }
257 | }
258 |
259 | // Create projection line for each X coordinate
260 | return uniqueXCoordinates.map((x) => {
261 | const line: ProjectionLine = {
262 | position: x,
263 | direction: "vertical",
264 | nodeIndices: [],
265 | };
266 |
267 | // Find all nodes intersecting this line
268 | for (let i = 0; i < rects.length; i++) {
269 | const rect = rects[i];
270 | if (x >= rect.left && x <= rect.left + rect.width) {
271 | line.nodeIndices.push(i);
272 | }
273 | }
274 |
275 | return line;
276 | });
277 | }
278 |
279 | /**
280 | * Group nodes by rows
281 | * @param nodes - Node array
282 | * @param tolerance - Tolerance in pixels
283 | */
284 | static groupNodesByRows(nodes: SimplifiedNode[], tolerance: number = 1): SimplifiedNode[][] {
285 | const rects = this.nodesToRects(nodes);
286 | if (rects.length === 0) return [nodes];
287 |
288 | const projectionLines = this.generateHorizontalProjectionLines(rects, tolerance);
289 | const rows: SimplifiedNode[][] = [];
290 |
291 | for (let i = 0; i < projectionLines.length - 1; i++) {
292 | const currentLine = projectionLines[i];
293 | const nextLine = projectionLines[i + 1];
294 |
295 | const nodesBetweenLines = new Set<number>();
296 |
297 | // Find nodes completely between these two lines
298 | for (let j = 0; j < rects.length; j++) {
299 | const rect = rects[j];
300 | if (rect.top >= currentLine.position && rect.top + rect.height <= nextLine.position) {
301 | nodesBetweenLines.add(j);
302 | }
303 | }
304 |
305 | if (nodesBetweenLines.size > 0) {
306 | // Sort nodes left to right
307 | const rowNodes = Array.from(nodesBetweenLines)
308 | .map((index) => nodes[index])
309 | .sort((a, b) => {
310 | const { left: aLeft } = getNodePosition(a);
311 | const { left: bLeft } = getNodePosition(b);
312 | return aLeft - bLeft;
313 | });
314 |
315 | rows.push(rowNodes);
316 | }
317 | }
318 |
319 | // If no rows found, treat all nodes as one row
320 | if (rows.length === 0) {
321 | rows.push([...nodes]);
322 | }
323 |
324 | return rows;
325 | }
326 |
327 | /**
328 | * Group row nodes by columns
329 | * @param rowNodes - Nodes in a row
330 | * @param tolerance - Tolerance in pixels
331 | */
332 | static groupRowNodesByColumns(
333 | rowNodes: SimplifiedNode[],
334 | tolerance: number = 1,
335 | ): SimplifiedNode[][] {
336 | const rects = this.nodesToRects(rowNodes);
337 | if (rects.length === 0) return [rowNodes];
338 |
339 | const projectionLines = this.generateVerticalProjectionLines(rects, tolerance);
340 | const columns: SimplifiedNode[][] = [];
341 |
342 | for (let i = 0; i < projectionLines.length - 1; i++) {
343 | const currentLine = projectionLines[i];
344 | const nextLine = projectionLines[i + 1];
345 |
346 | const nodesBetweenLines = new Set<number>();
347 |
348 | // Find nodes completely between these two lines
349 | for (let j = 0; j < rects.length; j++) {
350 | const rect = rects[j];
351 | if (rect.left >= currentLine.position && rect.left + rect.width <= nextLine.position) {
352 | nodesBetweenLines.add(j);
353 | }
354 | }
355 |
356 | if (nodesBetweenLines.size > 0) {
357 | const colNodes = Array.from(nodesBetweenLines).map((index) => rowNodes[index]);
358 | columns.push(colNodes);
359 | }
360 | }
361 |
362 | // If no columns found, treat all nodes as one column
363 | if (columns.length === 0) {
364 | columns.push([...rowNodes]);
365 | }
366 |
367 | return columns;
368 | }
369 |
370 | /**
371 | * Process node spatial relationships and build containment hierarchy
372 | * @param nodes - Node array
373 | */
374 | static processNodeRelationships(nodes: SimplifiedNode[]): SimplifiedNode[] {
375 | if (nodes.length <= 1) return [...nodes];
376 |
377 | const rects = this.nodesToRects(nodes);
378 | if (rects.length !== nodes.length) {
379 | return nodes; // Cannot process all nodes, return as-is
380 | }
381 |
382 | // Find all containment relationships
383 | const containsRelations: [number, number][] = [];
384 | for (let i = 0; i < rects.length; i++) {
385 | for (let j = 0; j < rects.length; j++) {
386 | if (i !== j && RectUtils.contains(rects[i], rects[j])) {
387 | containsRelations.push([i, j]); // Node i contains node j
388 | }
389 | }
390 | }
391 |
392 | // Build containment graph
393 | const childrenMap = new Map<number, Set<number>>();
394 | const parentMap = new Map<number, number | null>();
395 |
396 | // Initialize all nodes without parents
397 | for (let i = 0; i < nodes.length; i++) {
398 | parentMap.set(i, null);
399 | childrenMap.set(i, new Set<number>());
400 | }
401 |
402 | // Process containment relationships
403 | for (const [parent, child] of containsRelations) {
404 | childrenMap.get(parent)?.add(child);
405 | parentMap.set(child, parent);
406 | }
407 |
408 | // Fix multi-level containment - ensure each node has only its direct parent
409 | for (const [child, parent] of parentMap.entries()) {
410 | if (parent === null) continue;
411 |
412 | let currentParent = parent;
413 | let grandParent = parentMap.get(currentParent);
414 |
415 | while (grandParent !== null && grandParent !== undefined) {
416 | // If grandparent also directly contains child, remove parent-child direct relation
417 | if (childrenMap.get(grandParent)?.has(child)) {
418 | childrenMap.get(currentParent)?.delete(child);
419 | }
420 |
421 | currentParent = grandParent;
422 | grandParent = parentMap.get(currentParent);
423 | }
424 | }
425 |
426 | // Build new node tree structure
427 | const rootIndices = Array.from(parentMap.entries())
428 | .filter(([_, parent]) => parent === null)
429 | .map(([index]) => index);
430 |
431 | const result: SimplifiedNode[] = [];
432 |
433 | // Recursively build node tree
434 | const buildNodeTree = (nodeIndex: number): SimplifiedNode => {
435 | const node = { ...nodes[nodeIndex] };
436 | const childIndices = Array.from(childrenMap.get(nodeIndex) || []);
437 |
438 | if (childIndices.length > 0) {
439 | node.children = childIndices.map(buildNodeTree);
440 | }
441 |
442 | return node;
443 | };
444 |
445 | // Build from all root nodes
446 | for (const rootIndex of rootIndices) {
447 | result.push(buildNodeTree(rootIndex));
448 | }
449 |
450 | return result;
451 | }
452 | }
453 |
```
--------------------------------------------------------------------------------
/src/services/figma.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from "fs";
2 | import path from "path";
3 | import { parseFigmaResponse } from "~/core/parser.js";
4 | import type { SimplifiedDesign } from "~/types/index.js";
5 | import { cacheManager } from "./cache.js";
6 | import type {
7 | GetImagesResponse,
8 | GetFileResponse,
9 | GetFileNodesResponse,
10 | GetImageFillsResponse,
11 | } from "@figma/rest-api-spec";
12 | import { Logger } from "~/server.js";
13 | import type {
14 | FigmaError,
15 | RateLimitInfo,
16 | FetchImageParams,
17 | FetchImageFillParams,
18 | } from "~/types/index.js";
19 |
20 | // Re-export types for backward compatibility
21 | export type { FigmaError, RateLimitInfo, FetchImageParams, FetchImageFillParams };
22 |
23 | // ==================== Internal Types ====================
24 |
25 | /**
26 | * API Response Result (internal use only)
27 | */
28 | interface ApiResponse<T> {
29 | data: T;
30 | rateLimitInfo: RateLimitInfo;
31 | }
32 |
33 | // ==================== Utility Functions ====================
34 |
35 | /**
36 | * Validate fileKey format
37 | */
38 | function validateFileKey(fileKey: string): void {
39 | if (!fileKey || typeof fileKey !== "string") {
40 | throw createFigmaError(400, "fileKey is required");
41 | }
42 | // Figma fileKey is typically alphanumeric
43 | if (!/^[a-zA-Z0-9_-]+$/.test(fileKey)) {
44 | throw createFigmaError(400, `Invalid fileKey format: ${fileKey}`);
45 | }
46 | }
47 |
48 | /**
49 | * Validate nodeId format
50 | */
51 | function validateNodeId(nodeId: string): void {
52 | if (!nodeId || typeof nodeId !== "string") {
53 | throw createFigmaError(400, "nodeId is required");
54 | }
55 | // Figma nodeId format is typically number:number or number-number
56 | if (!/^[\d:_-]+$/.test(nodeId)) {
57 | throw createFigmaError(400, `Invalid nodeId format: ${nodeId}`);
58 | }
59 | }
60 |
61 | /**
62 | * Validate depth parameter
63 | */
64 | function validateDepth(depth?: number): void {
65 | if (depth !== undefined) {
66 | if (typeof depth !== "number" || depth < 1 || depth > 100) {
67 | throw createFigmaError(400, "depth must be a number between 1 and 100");
68 | }
69 | }
70 | }
71 |
72 | /**
73 | * Validate local path security
74 | */
75 | function validateLocalPath(localPath: string, fileName: string): string {
76 | const normalizedPath = path.resolve(localPath, fileName);
77 | const resolvedLocalPath = path.resolve(localPath);
78 |
79 | if (!normalizedPath.startsWith(resolvedLocalPath)) {
80 | throw createFigmaError(400, "Invalid file path: path traversal detected");
81 | }
82 |
83 | return normalizedPath;
84 | }
85 |
86 | /**
87 | * Create Figma error
88 | */
89 | function createFigmaError(
90 | status: number,
91 | message: string,
92 | rateLimitInfo?: RateLimitInfo,
93 | ): FigmaError {
94 | return {
95 | status,
96 | err: message,
97 | rateLimitInfo,
98 | };
99 | }
100 |
101 | /**
102 | * Extract Rate Limit information from response headers
103 | */
104 | function extractRateLimitInfo(headers: Headers): RateLimitInfo {
105 | return {
106 | remaining: headers.has("x-rate-limit-remaining")
107 | ? parseInt(headers.get("x-rate-limit-remaining")!, 10)
108 | : null,
109 | resetAfter: headers.has("x-rate-limit-reset")
110 | ? parseInt(headers.get("x-rate-limit-reset")!, 10)
111 | : null,
112 | retryAfter: headers.has("retry-after") ? parseInt(headers.get("retry-after")!, 10) : null,
113 | };
114 | }
115 |
116 | /**
117 | * Format Rate Limit error message
118 | */
119 | function formatRateLimitError(rateLimitInfo: RateLimitInfo): string {
120 | const parts: string[] = ["Figma API rate limit exceeded (429 Too Many Requests)."];
121 |
122 | if (rateLimitInfo.retryAfter !== null) {
123 | const minutes = Math.ceil(rateLimitInfo.retryAfter / 60);
124 | const hours = Math.ceil(rateLimitInfo.retryAfter / 3600);
125 | const days = Math.ceil(rateLimitInfo.retryAfter / 86400);
126 |
127 | if (days > 1) {
128 | parts.push(`Please retry after ${days} days.`);
129 | } else if (hours > 1) {
130 | parts.push(`Please retry after ${hours} hours.`);
131 | } else {
132 | parts.push(`Please retry after ${minutes} minutes.`);
133 | }
134 | }
135 |
136 | parts.push(
137 | "\nThis is likely due to Figma's November 2025 rate limit update.",
138 | "Starter plan: 6 requests/month. Professional plan: 10 requests/minute.",
139 | "\nSuggestions:",
140 | "1. Check if the design file belongs to a Starter plan workspace",
141 | "2. Duplicate the file to your own Professional workspace",
142 | "3. Wait for the rate limit to reset",
143 | );
144 |
145 | return parts.join(" ");
146 | }
147 |
148 | /**
149 | * Download image to local filesystem
150 | */
151 | async function downloadImage(
152 | url: string,
153 | localPath: string,
154 | fileName: string,
155 | fileKey: string,
156 | nodeId: string,
157 | format: string,
158 | ): Promise<string> {
159 | // Validate path security
160 | const fullPath = validateLocalPath(localPath, fileName);
161 |
162 | // Check image cache
163 | const cachedPath = await cacheManager.hasImage(fileKey, nodeId, format);
164 | if (cachedPath) {
165 | // Copy from cache to target path
166 | const copied = await cacheManager.copyImageFromCache(fileKey, nodeId, format, fullPath);
167 | if (copied) {
168 | Logger.log(`Image loaded from cache: ${fileName}`);
169 | return fullPath;
170 | }
171 | }
172 |
173 | // Ensure directory exists
174 | const dir = path.dirname(fullPath);
175 | if (!fs.existsSync(dir)) {
176 | fs.mkdirSync(dir, { recursive: true });
177 | }
178 |
179 | // Download image
180 | const response = await fetch(url, {
181 | method: "GET",
182 | signal: AbortSignal.timeout(30000), // 30 second timeout
183 | });
184 |
185 | if (!response.ok) {
186 | throw new Error(`Failed to download image: ${response.statusText}`);
187 | }
188 |
189 | // Use arrayBuffer instead of streaming for better reliability
190 | const buffer = await response.arrayBuffer();
191 | await fs.promises.writeFile(fullPath, Buffer.from(buffer));
192 |
193 | // Cache image
194 | await cacheManager.cacheImage(fullPath, fileKey, nodeId, format);
195 |
196 | return fullPath;
197 | }
198 |
199 | // ==================== Logging Utilities ====================
200 |
201 | /**
202 | * Write development logs
203 | */
204 | function writeLogs(name: string, value: unknown): void {
205 | try {
206 | if (process.env.NODE_ENV !== "development") return;
207 |
208 | const logsDir = "logs";
209 |
210 | try {
211 | fs.accessSync(process.cwd(), fs.constants.W_OK);
212 | } catch {
213 | return;
214 | }
215 |
216 | if (!fs.existsSync(logsDir)) {
217 | fs.mkdirSync(logsDir);
218 | }
219 | fs.writeFileSync(`${logsDir}/${name}`, JSON.stringify(value, null, 2));
220 | } catch {
221 | // Ignore log write errors
222 | }
223 | }
224 |
225 | // ==================== Figma Service Class ====================
226 |
227 | /**
228 | * Figma API Service
229 | */
230 | export class FigmaService {
231 | private readonly apiKey: string;
232 | private readonly baseUrl = "https://api.figma.com/v1";
233 |
234 | /** Most recent Rate Limit information */
235 | private lastRateLimitInfo: RateLimitInfo | null = null;
236 |
237 | constructor(apiKey: string) {
238 | if (!apiKey || typeof apiKey !== "string") {
239 | throw new Error("Figma API key is required");
240 | }
241 | this.apiKey = apiKey;
242 | }
243 |
244 | /**
245 | * Get most recent Rate Limit information
246 | */
247 | getRateLimitInfo(): RateLimitInfo | null {
248 | return this.lastRateLimitInfo;
249 | }
250 |
251 | /**
252 | * Make API request
253 | */
254 | private async request<T>(endpoint: string): Promise<ApiResponse<T>> {
255 | if (typeof fetch !== "function") {
256 | throw new Error(
257 | "The MCP server requires Node.js 18+ with fetch support.\n" +
258 | "Please upgrade your Node.js version to continue.",
259 | );
260 | }
261 |
262 | Logger.log(`Calling ${this.baseUrl}${endpoint}`);
263 |
264 | const response = await fetch(`${this.baseUrl}${endpoint}`, {
265 | headers: {
266 | "X-Figma-Token": this.apiKey,
267 | },
268 | });
269 |
270 | // Extract Rate Limit information
271 | const rateLimitInfo = extractRateLimitInfo(response.headers);
272 | this.lastRateLimitInfo = rateLimitInfo;
273 |
274 | // Handle error responses
275 | if (!response.ok) {
276 | const status = response.status;
277 | let errorMessage = response.statusText || "Unknown error";
278 |
279 | // Special handling for 429 errors
280 | if (status === 429) {
281 | errorMessage = formatRateLimitError(rateLimitInfo);
282 | } else if (status === 403) {
283 | errorMessage = "Access denied. Please check your Figma API key and file permissions.";
284 | } else if (status === 404) {
285 | errorMessage = "File or node not found. Please verify the fileKey and nodeId are correct.";
286 | }
287 |
288 | throw createFigmaError(status, errorMessage, rateLimitInfo);
289 | }
290 |
291 | const data = (await response.json()) as T;
292 | return { data, rateLimitInfo };
293 | }
294 |
295 | /**
296 | * Get image fill URLs and download
297 | */
298 | async getImageFills(
299 | fileKey: string,
300 | nodes: FetchImageFillParams[],
301 | localPath: string,
302 | ): Promise<string[]> {
303 | if (nodes.length === 0) return [];
304 |
305 | // Validate parameters
306 | validateFileKey(fileKey);
307 | nodes.forEach((node) => {
308 | validateNodeId(node.nodeId);
309 | });
310 |
311 | const endpoint = `/files/${fileKey}/images`;
312 | const { data } = await this.request<GetImageFillsResponse>(endpoint);
313 | const { images = {} } = data.meta;
314 |
315 | const downloads = nodes.map(async ({ imageRef, fileName, nodeId }) => {
316 | const imageUrl = images[imageRef];
317 | if (!imageUrl) {
318 | Logger.log(`Image not found for ref: ${imageRef}`);
319 | return "";
320 | }
321 |
322 | try {
323 | const format = fileName.toLowerCase().endsWith(".svg") ? "svg" : "png";
324 | return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, format);
325 | } catch (error) {
326 | Logger.error(`Failed to download image ${fileName}:`, error);
327 | return "";
328 | }
329 | });
330 |
331 | return Promise.all(downloads);
332 | }
333 |
334 | /**
335 | * Render nodes as images and download
336 | */
337 | async getImages(
338 | fileKey: string,
339 | nodes: FetchImageParams[],
340 | localPath: string,
341 | ): Promise<string[]> {
342 | if (nodes.length === 0) return [];
343 |
344 | // Validate parameters
345 | validateFileKey(fileKey);
346 | nodes.forEach((node) => validateNodeId(node.nodeId));
347 |
348 | // Categorize PNG and SVG nodes
349 | const pngNodes = nodes.filter(({ fileType }) => fileType === "png");
350 | const svgNodes = nodes.filter(({ fileType }) => fileType === "svg");
351 |
352 | // Get image URLs (sequential execution to reduce Rate Limit risk)
353 | const imageUrls: Record<string, string> = {};
354 |
355 | if (pngNodes.length > 0) {
356 | const pngIds = pngNodes.map(({ nodeId }) => nodeId).join(",");
357 | const { data } = await this.request<GetImagesResponse>(
358 | `/images/${fileKey}?ids=${pngIds}&scale=2&format=png`,
359 | );
360 | Object.assign(imageUrls, data.images || {});
361 | }
362 |
363 | if (svgNodes.length > 0) {
364 | const svgIds = svgNodes.map(({ nodeId }) => nodeId).join(",");
365 | const { data } = await this.request<GetImagesResponse>(
366 | `/images/${fileKey}?ids=${svgIds}&scale=2&format=svg`,
367 | );
368 | Object.assign(imageUrls, data.images || {});
369 | }
370 |
371 | // Download images
372 | const downloads = nodes.map(async ({ nodeId, fileName, fileType }) => {
373 | const imageUrl = imageUrls[nodeId];
374 | if (!imageUrl) {
375 | Logger.log(`Image URL not found for node: ${nodeId}`);
376 | return "";
377 | }
378 |
379 | try {
380 | return await downloadImage(imageUrl, localPath, fileName, fileKey, nodeId, fileType);
381 | } catch (error) {
382 | Logger.error(`Failed to download image ${fileName}:`, error);
383 | return "";
384 | }
385 | });
386 |
387 | return Promise.all(downloads);
388 | }
389 |
390 | /**
391 | * Get entire Figma file
392 | */
393 | async getFile(fileKey: string, depth?: number): Promise<SimplifiedDesign> {
394 | // Validate parameters
395 | validateFileKey(fileKey);
396 | validateDepth(depth);
397 |
398 | // Try to get from cache
399 | const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, undefined, depth);
400 | if (cached) {
401 | Logger.log(`File loaded from cache: ${fileKey}`);
402 | return cached;
403 | }
404 |
405 | try {
406 | const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`;
407 | Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
408 |
409 | const { data: response } = await this.request<GetFileResponse>(endpoint);
410 | Logger.log("Got response");
411 |
412 | const simplifiedResponse = parseFigmaResponse(response);
413 |
414 | // Write development logs
415 | writeLogs("figma-raw.json", response);
416 | writeLogs("figma-simplified.json", simplifiedResponse);
417 |
418 | // Write to cache
419 | await cacheManager.setNodeData(simplifiedResponse, fileKey, undefined, depth);
420 |
421 | return simplifiedResponse;
422 | } catch (error) {
423 | // Re-throw Figma errors to preserve details
424 | if ((error as FigmaError).status) {
425 | throw error;
426 | }
427 | Logger.error("Failed to get file:", error);
428 | throw error;
429 | }
430 | }
431 |
432 | /**
433 | * Get specific node
434 | */
435 | async getNode(fileKey: string, nodeId: string, depth?: number): Promise<SimplifiedDesign> {
436 | // Validate parameters
437 | validateFileKey(fileKey);
438 | validateNodeId(nodeId);
439 | validateDepth(depth);
440 |
441 | // Try to get from cache
442 | const cached = await cacheManager.getNodeData<SimplifiedDesign>(fileKey, nodeId, depth);
443 | if (cached) {
444 | Logger.log(`Node loaded from cache: ${fileKey}/${nodeId}`);
445 | return cached;
446 | }
447 |
448 | const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
449 | const { data: response } = await this.request<GetFileNodesResponse>(endpoint);
450 |
451 | Logger.log("Got response from getNode, now parsing.");
452 | writeLogs("figma-raw.json", response);
453 |
454 | const simplifiedResponse = parseFigmaResponse(response);
455 | writeLogs("figma-simplified.json", simplifiedResponse);
456 |
457 | // Write to cache
458 | await cacheManager.setNodeData(simplifiedResponse, fileKey, nodeId, depth);
459 |
460 | return simplifiedResponse;
461 | }
462 | }
463 |
```
--------------------------------------------------------------------------------
/src/services/cache/disk-cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Disk Cache
3 | *
4 | * Persistent file-based cache for Figma data and images.
5 | * Acts as L2 cache layer after in-memory LRU cache.
6 | *
7 | * @module services/cache/disk-cache
8 | */
9 |
10 | import fs from "fs";
11 | import path from "path";
12 | import crypto from "crypto";
13 | import os from "os";
14 | import type { DiskCacheConfig, CacheEntryMeta, DiskCacheStats } from "./types.js";
15 |
16 | /**
17 | * Default disk cache configuration
18 | */
19 | const DEFAULT_CONFIG: DiskCacheConfig = {
20 | cacheDir: path.join(os.homedir(), ".figma-mcp-cache"),
21 | maxSize: 500 * 1024 * 1024, // 500MB
22 | ttl: 24 * 60 * 60 * 1000, // 24 hours
23 | };
24 |
25 | /**
26 | * Disk-based persistent cache
27 | */
28 | export class DiskCache {
29 | private config: DiskCacheConfig;
30 | private dataDir: string;
31 | private imageDir: string;
32 | private metadataDir: string;
33 | private stats: { hits: number; misses: number };
34 |
35 | constructor(config: Partial<DiskCacheConfig> = {}) {
36 | this.config = { ...DEFAULT_CONFIG, ...config };
37 | this.dataDir = path.join(this.config.cacheDir, "data");
38 | this.imageDir = path.join(this.config.cacheDir, "images");
39 | this.metadataDir = path.join(this.config.cacheDir, "metadata");
40 | this.stats = { hits: 0, misses: 0 };
41 |
42 | this.ensureCacheDirectories();
43 | }
44 |
45 | /**
46 | * Ensure cache directories exist
47 | */
48 | private ensureCacheDirectories(): void {
49 | try {
50 | [this.config.cacheDir, this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
51 | if (!fs.existsSync(dir)) {
52 | fs.mkdirSync(dir, { recursive: true });
53 | }
54 | });
55 | } catch (error) {
56 | console.warn("Failed to create cache directories:", error);
57 | }
58 | }
59 |
60 | /**
61 | * Generate cache key from components
62 | */
63 | static generateKey(fileKey: string, nodeId?: string, depth?: number): string {
64 | const keyParts = [fileKey];
65 | if (nodeId) keyParts.push(`node-${nodeId}`);
66 | if (depth !== undefined) keyParts.push(`depth-${depth}`);
67 |
68 | const keyString = keyParts.join("_");
69 | return crypto.createHash("md5").update(keyString).digest("hex");
70 | }
71 |
72 | /**
73 | * Generate image cache key
74 | */
75 | static generateImageKey(fileKey: string, nodeId: string, format: string): string {
76 | const keyString = `${fileKey}_${nodeId}_${format}`;
77 | return crypto.createHash("md5").update(keyString).digest("hex");
78 | }
79 |
80 | // ==================== Node Data Operations ====================
81 |
82 | /**
83 | * Get cached node data
84 | */
85 | async get<T>(
86 | fileKey: string,
87 | nodeId?: string,
88 | depth?: number,
89 | version?: string,
90 | ): Promise<T | null> {
91 | try {
92 | const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
93 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
94 | const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
95 |
96 | // Check if files exist
97 | if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
98 | this.stats.misses++;
99 | return null;
100 | }
101 |
102 | // Read metadata and check expiration
103 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
104 |
105 | // Check TTL expiration
106 | if (Date.now() > metadata.expiresAt) {
107 | this.deleteByKey(cacheKey);
108 | this.stats.misses++;
109 | return null;
110 | }
111 |
112 | // Check version mismatch
113 | if (version && metadata.version && metadata.version !== version) {
114 | this.deleteByKey(cacheKey);
115 | this.stats.misses++;
116 | return null;
117 | }
118 |
119 | // Read and return cached data
120 | const data = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
121 | this.stats.hits++;
122 | return data as T;
123 | } catch (error) {
124 | console.warn("Failed to read disk cache:", error);
125 | this.stats.misses++;
126 | return null;
127 | }
128 | }
129 |
130 | /**
131 | * Set node data cache
132 | */
133 | async set<T>(
134 | data: T,
135 | fileKey: string,
136 | nodeId?: string,
137 | depth?: number,
138 | version?: string,
139 | ): Promise<void> {
140 | try {
141 | const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
142 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
143 | const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
144 |
145 | const dataString = JSON.stringify(data, null, 2);
146 |
147 | // Create metadata
148 | const metadata: CacheEntryMeta = {
149 | key: cacheKey,
150 | createdAt: Date.now(),
151 | expiresAt: Date.now() + this.config.ttl,
152 | fileKey,
153 | nodeId,
154 | depth,
155 | version,
156 | size: Buffer.byteLength(dataString, "utf-8"),
157 | };
158 |
159 | // Write data and metadata
160 | fs.writeFileSync(dataPath, dataString);
161 | fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
162 |
163 | // Enforce size limit asynchronously
164 | this.enforceSizeLimit().catch(() => {});
165 | } catch (error) {
166 | console.warn("Failed to write disk cache:", error);
167 | }
168 | }
169 |
170 | /**
171 | * Check if cache entry exists
172 | */
173 | async has(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
174 | const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
175 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
176 | const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
177 |
178 | if (!fs.existsSync(dataPath) || !fs.existsSync(metadataPath)) {
179 | return false;
180 | }
181 |
182 | try {
183 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
184 | return Date.now() <= metadata.expiresAt;
185 | } catch {
186 | return false;
187 | }
188 | }
189 |
190 | /**
191 | * Delete cache entry by key
192 | */
193 | private deleteByKey(cacheKey: string): void {
194 | try {
195 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
196 | const metadataPath = path.join(this.metadataDir, `${cacheKey}.meta.json`);
197 |
198 | if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
199 | if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
200 | } catch {
201 | // Ignore deletion errors
202 | }
203 | }
204 |
205 | /**
206 | * Delete cache for a file key
207 | */
208 | async delete(fileKey: string, nodeId?: string, depth?: number): Promise<boolean> {
209 | const cacheKey = DiskCache.generateKey(fileKey, nodeId, depth);
210 | this.deleteByKey(cacheKey);
211 | return true;
212 | }
213 |
214 | /**
215 | * Invalidate all cache entries for a file
216 | */
217 | async invalidateFile(fileKey: string): Promise<number> {
218 | let invalidated = 0;
219 |
220 | try {
221 | const metadataFiles = fs.readdirSync(this.metadataDir);
222 |
223 | for (const file of metadataFiles) {
224 | if (!file.endsWith(".meta.json") || file.startsWith("img_")) continue;
225 |
226 | const metadataPath = path.join(this.metadataDir, file);
227 | try {
228 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
229 |
230 | if (metadata.fileKey === fileKey) {
231 | const cacheKey = file.replace(".meta.json", "");
232 | this.deleteByKey(cacheKey);
233 | invalidated++;
234 | }
235 | } catch {
236 | // Skip individual file errors
237 | }
238 | }
239 | } catch (error) {
240 | console.warn("Failed to invalidate file cache:", error);
241 | }
242 |
243 | return invalidated;
244 | }
245 |
246 | // ==================== Image Operations ====================
247 |
248 | /**
249 | * Check if image is cached
250 | */
251 | async hasImage(fileKey: string, nodeId: string, format: string): Promise<string | null> {
252 | try {
253 | const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
254 | const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
255 | const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
256 |
257 | if (!fs.existsSync(imagePath) || !fs.existsSync(metadataPath)) {
258 | return null;
259 | }
260 |
261 | // Check expiration
262 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
263 | if (Date.now() > metadata.expiresAt) {
264 | this.deleteImageByKey(cacheKey, format);
265 | return null;
266 | }
267 |
268 | return imagePath;
269 | } catch {
270 | return null;
271 | }
272 | }
273 |
274 | /**
275 | * Cache image file
276 | */
277 | async cacheImage(
278 | sourcePath: string,
279 | fileKey: string,
280 | nodeId: string,
281 | format: string,
282 | ): Promise<string> {
283 | try {
284 | const cacheKey = DiskCache.generateImageKey(fileKey, nodeId, format);
285 | const cachedImagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
286 | const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
287 |
288 | // Copy image to cache
289 | fs.copyFileSync(sourcePath, cachedImagePath);
290 |
291 | // Get file size
292 | const stats = fs.statSync(cachedImagePath);
293 |
294 | // Create metadata
295 | const metadata: CacheEntryMeta = {
296 | key: cacheKey,
297 | createdAt: Date.now(),
298 | expiresAt: Date.now() + this.config.ttl,
299 | fileKey,
300 | nodeId,
301 | size: stats.size,
302 | };
303 | fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
304 |
305 | return cachedImagePath;
306 | } catch (error) {
307 | console.warn("Failed to cache image:", error);
308 | return sourcePath;
309 | }
310 | }
311 |
312 | /**
313 | * Copy image from cache to target path
314 | */
315 | async copyImageFromCache(
316 | fileKey: string,
317 | nodeId: string,
318 | format: string,
319 | targetPath: string,
320 | ): Promise<boolean> {
321 | const cachedPath = await this.hasImage(fileKey, nodeId, format);
322 | if (!cachedPath) return false;
323 |
324 | try {
325 | const targetDir = path.dirname(targetPath);
326 | if (!fs.existsSync(targetDir)) {
327 | fs.mkdirSync(targetDir, { recursive: true });
328 | }
329 |
330 | fs.copyFileSync(cachedPath, targetPath);
331 | return true;
332 | } catch {
333 | return false;
334 | }
335 | }
336 |
337 | /**
338 | * Delete image cache by key
339 | */
340 | private deleteImageByKey(cacheKey: string, format: string): void {
341 | try {
342 | const imagePath = path.join(this.imageDir, `${cacheKey}.${format.toLowerCase()}`);
343 | const metadataPath = path.join(this.metadataDir, `img_${cacheKey}.meta.json`);
344 |
345 | if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
346 | if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath);
347 | } catch {
348 | // Ignore errors
349 | }
350 | }
351 |
352 | // ==================== Maintenance Operations ====================
353 |
354 | /**
355 | * Clean all expired cache entries
356 | */
357 | async cleanExpired(): Promise<number> {
358 | let deletedCount = 0;
359 | const now = Date.now();
360 |
361 | try {
362 | const metadataFiles = fs.readdirSync(this.metadataDir);
363 |
364 | for (const file of metadataFiles) {
365 | if (!file.endsWith(".meta.json")) continue;
366 |
367 | const metadataPath = path.join(this.metadataDir, file);
368 | try {
369 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
370 |
371 | if (now > metadata.expiresAt) {
372 | const cacheKey = file.replace(".meta.json", "");
373 |
374 | if (file.startsWith("img_")) {
375 | // Image cache
376 | const imgCacheKey = cacheKey.replace("img_", "");
377 | ["png", "jpg", "svg", "pdf"].forEach((format) => {
378 | const imagePath = path.join(this.imageDir, `${imgCacheKey}.${format}`);
379 | if (fs.existsSync(imagePath)) fs.unlinkSync(imagePath);
380 | });
381 | } else {
382 | // Data cache
383 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
384 | if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath);
385 | }
386 |
387 | fs.unlinkSync(metadataPath);
388 | deletedCount++;
389 | }
390 | } catch {
391 | // Skip individual errors
392 | }
393 | }
394 | } catch (error) {
395 | console.warn("Failed to clean expired cache:", error);
396 | }
397 |
398 | return deletedCount;
399 | }
400 |
401 | /**
402 | * Enforce size limit by removing oldest entries
403 | */
404 | async enforceSizeLimit(): Promise<number> {
405 | let removedCount = 0;
406 |
407 | try {
408 | const stats = await this.getStats();
409 | if (stats.totalSize <= this.config.maxSize) {
410 | return 0;
411 | }
412 |
413 | // Get all metadata entries sorted by creation time
414 | const entries: Array<{ path: string; meta: CacheEntryMeta }> = [];
415 | const metadataFiles = fs.readdirSync(this.metadataDir);
416 |
417 | for (const file of metadataFiles) {
418 | if (!file.endsWith(".meta.json")) continue;
419 |
420 | const metadataPath = path.join(this.metadataDir, file);
421 | try {
422 | const metadata: CacheEntryMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
423 | entries.push({ path: metadataPath, meta: metadata });
424 | } catch {
425 | // Skip invalid entries
426 | }
427 | }
428 |
429 | // Sort by creation time (oldest first)
430 | entries.sort((a, b) => a.meta.createdAt - b.meta.createdAt);
431 |
432 | // Remove oldest entries until under limit
433 | let currentSize = stats.totalSize;
434 | for (const entry of entries) {
435 | if (currentSize <= this.config.maxSize) break;
436 |
437 | const cacheKey = path.basename(entry.path).replace(".meta.json", "");
438 |
439 | if (cacheKey.startsWith("img_")) {
440 | const imgKey = cacheKey.replace("img_", "");
441 | ["png", "jpg", "svg", "pdf"].forEach((format) => {
442 | const imagePath = path.join(this.imageDir, `${imgKey}.${format}`);
443 | if (fs.existsSync(imagePath)) {
444 | currentSize -= fs.statSync(imagePath).size;
445 | fs.unlinkSync(imagePath);
446 | }
447 | });
448 | } else {
449 | const dataPath = path.join(this.dataDir, `${cacheKey}.json`);
450 | if (fs.existsSync(dataPath)) {
451 | currentSize -= fs.statSync(dataPath).size;
452 | fs.unlinkSync(dataPath);
453 | }
454 | }
455 |
456 | fs.unlinkSync(entry.path);
457 | currentSize -= entry.meta.size || 0;
458 | removedCount++;
459 | }
460 | } catch (error) {
461 | console.warn("Failed to enforce size limit:", error);
462 | }
463 |
464 | return removedCount;
465 | }
466 |
467 | /**
468 | * Clear all cache
469 | */
470 | async clearAll(): Promise<void> {
471 | try {
472 | [this.dataDir, this.imageDir, this.metadataDir].forEach((dir) => {
473 | if (fs.existsSync(dir)) {
474 | const files = fs.readdirSync(dir);
475 | files.forEach((file) => {
476 | fs.unlinkSync(path.join(dir, file));
477 | });
478 | }
479 | });
480 | this.stats = { hits: 0, misses: 0 };
481 | } catch (error) {
482 | console.warn("Failed to clear cache:", error);
483 | }
484 | }
485 |
486 | /**
487 | * Get cache statistics
488 | */
489 | async getStats(): Promise<DiskCacheStats> {
490 | let totalSize = 0;
491 | let nodeFileCount = 0;
492 | let imageFileCount = 0;
493 |
494 | try {
495 | if (fs.existsSync(this.dataDir)) {
496 | const dataFiles = fs.readdirSync(this.dataDir);
497 | nodeFileCount = dataFiles.filter((f) => f.endsWith(".json")).length;
498 | dataFiles.forEach((file) => {
499 | const stat = fs.statSync(path.join(this.dataDir, file));
500 | totalSize += stat.size;
501 | });
502 | }
503 |
504 | if (fs.existsSync(this.imageDir)) {
505 | const imageFiles = fs.readdirSync(this.imageDir);
506 | imageFileCount = imageFiles.length;
507 | imageFiles.forEach((file) => {
508 | const stat = fs.statSync(path.join(this.imageDir, file));
509 | totalSize += stat.size;
510 | });
511 | }
512 | } catch {
513 | // Ignore errors
514 | }
515 |
516 | return {
517 | hits: this.stats.hits,
518 | misses: this.stats.misses,
519 | totalSize,
520 | maxSize: this.config.maxSize,
521 | nodeFileCount,
522 | imageFileCount,
523 | };
524 | }
525 |
526 | /**
527 | * Get cache directory path
528 | */
529 | getCacheDir(): string {
530 | return this.config.cacheDir;
531 | }
532 | }
533 |
```
--------------------------------------------------------------------------------
/src/algorithms/icon/detector.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Icon Detection Algorithm
3 | *
4 | * Industry-based algorithm for detecting icons and mergeable layer groups.
5 | *
6 | * Core strategies:
7 | * 1. Prioritize Figma exportSettings (designer-marked exports)
8 | * 2. Smart detection: based on size, type ratio, structure depth
9 | * 3. Bottom-up merging: child icon groups merge first, then parent nodes
10 | *
11 | * @module algorithms/icon/detector
12 | */
13 |
14 | import type { IconDetectionResult, IconDetectionConfig } from "~/types/index.js";
15 |
16 | // Re-export types for module consumers
17 | export type { IconDetectionResult };
18 |
19 | // Use IconDetectionConfig from types, alias as DetectionConfig for internal use
20 | export type DetectionConfig = IconDetectionConfig;
21 |
22 | // ==================== Module-Specific Types ====================
23 |
24 | /**
25 | * Figma node structure for icon detection (minimal interface)
26 | */
27 | export interface FigmaNode {
28 | id: string;
29 | name: string;
30 | type: string;
31 | children?: FigmaNode[];
32 | absoluteBoundingBox?: {
33 | x: number;
34 | y: number;
35 | width: number;
36 | height: number;
37 | };
38 | exportSettings?: Array<{
39 | format: string;
40 | suffix?: string;
41 | constraint?: {
42 | type: string;
43 | value: number;
44 | };
45 | }>;
46 | fills?: Array<{
47 | type: string;
48 | visible?: boolean;
49 | imageRef?: string;
50 | blendMode?: string;
51 | }>;
52 | effects?: Array<{
53 | type: string;
54 | visible?: boolean;
55 | }>;
56 | strokes?: Array<unknown>;
57 | }
58 |
59 | // ==================== Constants ====================
60 |
61 | /** Default detection configuration */
62 | export const DEFAULT_CONFIG: DetectionConfig = {
63 | maxIconSize: 300,
64 | minIconSize: 8,
65 | mergeableRatio: 0.6,
66 | maxDepth: 5,
67 | maxChildren: 100,
68 | respectExportSettingsMaxSize: 400,
69 | };
70 |
71 | /** Container node types */
72 | const CONTAINER_TYPES = ["GROUP", "FRAME", "COMPONENT", "INSTANCE"] as const;
73 |
74 | /** Mergeable graphics types (can be represented as SVG) */
75 | const MERGEABLE_TYPES = [
76 | "VECTOR",
77 | "RECTANGLE",
78 | "ELLIPSE",
79 | "LINE",
80 | "POLYGON",
81 | "STAR",
82 | "BOOLEAN_OPERATION",
83 | "REGULAR_POLYGON",
84 | ] as const;
85 |
86 | /** Single element types that should not be auto-exported (typically backgrounds) */
87 | const SINGLE_ELEMENT_EXCLUDE_TYPES = ["RECTANGLE"] as const;
88 |
89 | /** Types that exclude a group from being merged as icon */
90 | const EXCLUDE_TYPES = ["TEXT", "COMPONENT", "INSTANCE"] as const;
91 |
92 | /** Effects that require PNG export */
93 | const PNG_REQUIRED_EFFECTS = [
94 | "DROP_SHADOW",
95 | "INNER_SHADOW",
96 | "LAYER_BLUR",
97 | "BACKGROUND_BLUR",
98 | ] as const;
99 |
100 | // ==================== Helper Functions ====================
101 |
102 | /**
103 | * Check if type is a container type
104 | */
105 | function isContainerType(type: string): boolean {
106 | return CONTAINER_TYPES.includes(type as (typeof CONTAINER_TYPES)[number]);
107 | }
108 |
109 | /**
110 | * Check if type is mergeable (can be part of an icon)
111 | */
112 | function isMergeableType(type: string): boolean {
113 | return MERGEABLE_TYPES.includes(type as (typeof MERGEABLE_TYPES)[number]);
114 | }
115 |
116 | /**
117 | * Check if type should be excluded from icon merging
118 | */
119 | function isExcludeType(type: string): boolean {
120 | return EXCLUDE_TYPES.includes(type as (typeof EXCLUDE_TYPES)[number]);
121 | }
122 |
123 | /**
124 | * Get node dimensions
125 | */
126 | function getNodeSize(node: FigmaNode): { width: number; height: number } | null {
127 | if (!node.absoluteBoundingBox) return null;
128 | return {
129 | width: node.absoluteBoundingBox.width,
130 | height: node.absoluteBoundingBox.height,
131 | };
132 | }
133 |
134 | /**
135 | * Check if node has image fill
136 | */
137 | function hasImageFill(node: FigmaNode): boolean {
138 | if (!node.fills) return false;
139 | return node.fills.some(
140 | (fill) => fill.type === "IMAGE" && fill.visible !== false && fill.imageRef,
141 | );
142 | }
143 |
144 | /**
145 | * Check if node has complex effects (requires PNG)
146 | */
147 | function hasComplexEffects(node: FigmaNode): boolean {
148 | if (!node.effects) return false;
149 | return node.effects.some(
150 | (effect) =>
151 | effect.visible !== false &&
152 | PNG_REQUIRED_EFFECTS.includes(effect.type as (typeof PNG_REQUIRED_EFFECTS)[number]),
153 | );
154 | }
155 |
156 | // ==================== Optimized Single-Pass Stats Collection ====================
157 |
158 | /**
159 | * Statistics collected from a node tree in a single traversal
160 | * This replaces multiple recursive functions with one unified pass
161 | */
162 | interface NodeTreeStats {
163 | /** Maximum depth of the tree */
164 | depth: number;
165 | /** Total number of descendants (not including root) */
166 | totalChildren: number;
167 | /** Whether tree contains excluded types (TEXT, COMPONENT, INSTANCE) */
168 | hasExcludeType: boolean;
169 | /** Whether tree contains image fills */
170 | hasImageFill: boolean;
171 | /** Whether tree contains complex effects requiring PNG */
172 | hasComplexEffects: boolean;
173 | /** Whether all leaf nodes are mergeable types */
174 | allLeavesMergeable: boolean;
175 | /** Ratio of mergeable types in direct children */
176 | mergeableRatio: number;
177 | }
178 |
179 | /**
180 | * Collect all tree statistics in a single traversal
181 | *
182 | * OPTIMIZATION: This replaces 6 separate recursive functions:
183 | * - calculateDepth()
184 | * - countTotalChildren()
185 | * - hasExcludeTypeInTree()
186 | * - hasImageFillInTree()
187 | * - hasComplexEffectsInTree()
188 | * - areAllLeavesMergeable()
189 | *
190 | * Before: O(6n) - each function traverses entire tree
191 | * After: O(n) - single traversal collects all data
192 | *
193 | * @param node - Node to analyze
194 | * @returns Collected statistics
195 | */
196 | function collectNodeStats(node: FigmaNode): NodeTreeStats {
197 | // Base case: leaf node (no children)
198 | if (!node.children || node.children.length === 0) {
199 | const isMergeable = isMergeableType(node.type);
200 | return {
201 | depth: 0,
202 | totalChildren: 0,
203 | hasExcludeType: isExcludeType(node.type),
204 | hasImageFill: hasImageFill(node),
205 | hasComplexEffects: hasComplexEffects(node),
206 | allLeavesMergeable: isMergeable,
207 | mergeableRatio: isMergeable ? 1 : 0,
208 | };
209 | }
210 |
211 | // Recursive case: collect stats from all children
212 | const childStats = node.children.map(collectNodeStats);
213 |
214 | // Aggregate child statistics
215 | const maxChildDepth = Math.max(...childStats.map((s) => s.depth));
216 | const totalDescendants = childStats.reduce((sum, s) => sum + 1 + s.totalChildren, 0);
217 | const hasExcludeInChildren = childStats.some((s) => s.hasExcludeType);
218 | const hasImageInChildren = childStats.some((s) => s.hasImageFill);
219 | const hasEffectsInChildren = childStats.some((s) => s.hasComplexEffects);
220 | const allChildrenMergeable = childStats.every((s) => s.allLeavesMergeable);
221 |
222 | // Calculate mergeable ratio for direct children
223 | const mergeableCount = node.children.filter(
224 | (child) => isMergeableType(child.type) || isContainerType(child.type),
225 | ).length;
226 | const mergeableRatio = mergeableCount / node.children.length;
227 |
228 | // Determine if all leaves are mergeable
229 | // For containers: all children must have all leaves mergeable
230 | // For other types: check if this type itself is mergeable
231 | const allLeavesMergeable = isContainerType(node.type)
232 | ? allChildrenMergeable
233 | : isMergeableType(node.type);
234 |
235 | return {
236 | depth: maxChildDepth + 1,
237 | totalChildren: totalDescendants,
238 | hasExcludeType: isExcludeType(node.type) || hasExcludeInChildren,
239 | hasImageFill: hasImageFill(node) || hasImageInChildren,
240 | hasComplexEffects: hasComplexEffects(node) || hasEffectsInChildren,
241 | allLeavesMergeable,
242 | mergeableRatio,
243 | };
244 | }
245 |
246 | // ==================== Main Detection Functions ====================
247 |
248 | /**
249 | * Detect if a single node should be exported as an icon
250 | *
251 | * OPTIMIZED: Uses single-pass collectNodeStats() instead of multiple recursive functions
252 | *
253 | * @param node - Figma node to analyze
254 | * @param config - Detection configuration
255 | * @returns Detection result with export recommendation
256 | */
257 | export function detectIcon(
258 | node: FigmaNode,
259 | config: DetectionConfig = DEFAULT_CONFIG,
260 | ): IconDetectionResult {
261 | const result: IconDetectionResult = {
262 | nodeId: node.id,
263 | nodeName: node.name,
264 | shouldMerge: false,
265 | exportFormat: "SVG",
266 | reason: "",
267 | };
268 |
269 | // Get node size once
270 | const size = getNodeSize(node);
271 | if (size) {
272 | result.size = size;
273 | }
274 |
275 | // 1. Check Figma exportSettings (with size restrictions)
276 | if (node.exportSettings && node.exportSettings.length > 0) {
277 | const isSmallEnough =
278 | !size ||
279 | (size.width <= config.respectExportSettingsMaxSize &&
280 | size.height <= config.respectExportSettingsMaxSize);
281 |
282 | // For exportSettings, we need to check for excluded types
283 | // Use optimized single-pass collection
284 | const stats = collectNodeStats(node);
285 | const containsText = stats.hasExcludeType;
286 |
287 | if (isSmallEnough && !containsText) {
288 | const exportSetting = node.exportSettings[0];
289 | result.shouldMerge = true;
290 | result.exportFormat = exportSetting.format === "SVG" ? "SVG" : "PNG";
291 | result.reason = `Designer marked export as ${exportSetting.format}`;
292 | return result;
293 | }
294 | // Large nodes or nodes with TEXT: ignore exportSettings, continue detection
295 | }
296 |
297 | // 2. Must be container type or mergeable single element
298 | if (!isContainerType(node.type)) {
299 | // Single mergeable type node
300 | if (isMergeableType(node.type)) {
301 | // Single RECTANGLE is typically a background, not exported
302 | if (
303 | SINGLE_ELEMENT_EXCLUDE_TYPES.includes(
304 | node.type as (typeof SINGLE_ELEMENT_EXCLUDE_TYPES)[number],
305 | )
306 | ) {
307 | result.reason = `Single ${node.type} is typically a background, not exported`;
308 | return result;
309 | }
310 |
311 | // Check size for single elements
312 | if (size) {
313 | if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
314 | result.reason = `Single element too large (${Math.round(size.width)}x${Math.round(size.height)} > ${config.maxIconSize})`;
315 | return result;
316 | }
317 | }
318 | result.shouldMerge = true;
319 | result.exportFormat = hasComplexEffects(node) ? "PNG" : "SVG";
320 | result.reason = "Single vector/shape element";
321 | return result;
322 | }
323 | result.reason = "Not a container or mergeable type";
324 | return result;
325 | }
326 |
327 | // 3. Check size
328 | if (size) {
329 | // Too large: likely a layout container
330 | if (size.width > config.maxIconSize || size.height > config.maxIconSize) {
331 | result.reason = `Size too large (${size.width}x${size.height} > ${config.maxIconSize})`;
332 | return result;
333 | }
334 |
335 | // Too small
336 | if (size.width < config.minIconSize && size.height < config.minIconSize) {
337 | result.reason = `Size too small (${size.width}x${size.height} < ${config.minIconSize})`;
338 | return result;
339 | }
340 | }
341 |
342 | // OPTIMIZATION: Collect all tree statistics in a single pass
343 | // This replaces 6 separate recursive traversals with 1
344 | const stats = collectNodeStats(node);
345 |
346 | // 4. Check for excluded types (TEXT, etc.)
347 | if (stats.hasExcludeType) {
348 | result.reason = "Contains TEXT or other exclude types";
349 | return result;
350 | }
351 |
352 | // 5. Check structure depth
353 | if (stats.depth > config.maxDepth) {
354 | result.reason = `Depth too deep (${stats.depth} > ${config.maxDepth})`;
355 | return result;
356 | }
357 |
358 | // 6. Check child count
359 | result.childCount = stats.totalChildren;
360 | if (stats.totalChildren > config.maxChildren) {
361 | result.reason = `Too many children (${stats.totalChildren} > ${config.maxChildren})`;
362 | return result;
363 | }
364 |
365 | // 7. Check mergeable type ratio
366 | if (stats.mergeableRatio < config.mergeableRatio) {
367 | result.reason = `Mergeable ratio too low (${(stats.mergeableRatio * 100).toFixed(1)}% < ${config.mergeableRatio * 100}%)`;
368 | return result;
369 | }
370 |
371 | // 8. Check if all leaf nodes are mergeable
372 | if (!stats.allLeavesMergeable) {
373 | result.reason = "Not all leaf nodes are mergeable types";
374 | return result;
375 | }
376 |
377 | // 9. Determine export format (using stats collected in single pass)
378 | if (stats.hasImageFill) {
379 | result.exportFormat = "PNG";
380 | result.reason = "Contains image fills, export as PNG";
381 | } else if (stats.hasComplexEffects) {
382 | result.exportFormat = "PNG";
383 | result.reason = "Contains complex effects, export as PNG";
384 | } else {
385 | result.exportFormat = "SVG";
386 | result.reason = "All vector elements, export as SVG";
387 | }
388 |
389 | result.shouldMerge = true;
390 | return result;
391 | }
392 |
393 | /**
394 | * Process node tree bottom-up, detecting and marking icons
395 | *
396 | * @param node - Root node
397 | * @param config - Detection configuration
398 | * @returns Processed node with _iconDetection markers
399 | */
400 | export function processNodeTree(
401 | node: FigmaNode,
402 | config: DetectionConfig = DEFAULT_CONFIG,
403 | ): FigmaNode & { _iconDetection?: IconDetectionResult } {
404 | const processedNode = { ...node } as FigmaNode & { _iconDetection?: IconDetectionResult };
405 |
406 | // Process children first (bottom-up)
407 | if (node.children && node.children.length > 0) {
408 | processedNode.children = node.children.map((child) => processNodeTree(child, config));
409 |
410 | // Check if all children are marked as icons (can be merged to parent)
411 | const allChildrenAreIcons = processedNode.children.every((child) => {
412 | const childWithDetection = child as FigmaNode & { _iconDetection?: IconDetectionResult };
413 | return childWithDetection._iconDetection?.shouldMerge;
414 | });
415 |
416 | // If all children are icons, try to merge to current node
417 | if (allChildrenAreIcons) {
418 | const detection = detectIcon(processedNode, config);
419 | if (detection.shouldMerge) {
420 | processedNode._iconDetection = detection;
421 | // Clear child markers since they will be merged
422 | processedNode.children.forEach((child) => {
423 | delete (child as FigmaNode & { _iconDetection?: IconDetectionResult })._iconDetection;
424 | });
425 | return processedNode;
426 | }
427 | }
428 | }
429 |
430 | // Detect current node
431 | const detection = detectIcon(processedNode, config);
432 | if (detection.shouldMerge) {
433 | processedNode._iconDetection = detection;
434 | }
435 |
436 | return processedNode;
437 | }
438 |
439 | /**
440 | * Collect all exportable icons from processed node tree
441 | *
442 | * @param node - Processed node with _iconDetection markers
443 | * @returns Array of icon detection results
444 | */
445 | export function collectExportableIcons(
446 | node: FigmaNode & { _iconDetection?: IconDetectionResult },
447 | ): IconDetectionResult[] {
448 | const results: IconDetectionResult[] = [];
449 |
450 | // If current node is an icon, add to results
451 | if (node._iconDetection?.shouldMerge) {
452 | results.push(node._iconDetection);
453 | // Don't recurse into children (they will be merged)
454 | return results;
455 | }
456 |
457 | // Recurse into children
458 | if (node.children) {
459 | for (const child of node.children) {
460 | results.push(
461 | ...collectExportableIcons(child as FigmaNode & { _iconDetection?: IconDetectionResult }),
462 | );
463 | }
464 | }
465 |
466 | return results;
467 | }
468 |
469 | /**
470 | * Analyze node tree and return icon detection report
471 | *
472 | * @param node - Root Figma node
473 | * @param config - Detection configuration
474 | * @returns Analysis result with processed tree, exportable icons, and summary
475 | */
476 | export function analyzeNodeTree(
477 | node: FigmaNode,
478 | config: DetectionConfig = DEFAULT_CONFIG,
479 | ): {
480 | processedTree: FigmaNode & { _iconDetection?: IconDetectionResult };
481 | exportableIcons: IconDetectionResult[];
482 | summary: {
483 | totalIcons: number;
484 | svgCount: number;
485 | pngCount: number;
486 | };
487 | } {
488 | const processedTree = processNodeTree(node, config);
489 | const exportableIcons = collectExportableIcons(processedTree);
490 |
491 | const summary = {
492 | totalIcons: exportableIcons.length,
493 | svgCount: exportableIcons.filter((i) => i.exportFormat === "SVG").length,
494 | pngCount: exportableIcons.filter((i) => i.exportFormat === "PNG").length,
495 | };
496 |
497 | return { processedTree, exportableIcons, summary };
498 | }
499 |
```
--------------------------------------------------------------------------------
/tests/integration/layout-optimization.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Layout Optimization Integration Tests
3 | *
4 | * Tests the complete layout optimization pipeline using real Figma data.
5 | * Converted from scripts/test-layout-optimization.ts
6 | */
7 |
8 | import { describe, it, expect, beforeAll } from "vitest";
9 | import * as fs from "fs";
10 | import * as path from "path";
11 | import { fileURLToPath } from "url";
12 | import { parseFigmaResponse } from "~/core/parser.js";
13 | import { LayoutOptimizer } from "~/algorithms/layout/optimizer.js";
14 | import type { SimplifiedNode, SimplifiedDesign } from "~/types/index.js";
15 |
16 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
17 | const fixturesDir = path.join(__dirname, "../fixtures/figma-data");
18 | const expectedDir = path.join(__dirname, "../fixtures/expected");
19 |
20 | // Test file configurations
21 | const TEST_FILES = [
22 | { name: "node-402-34955", desc: "Group 1410104853 (1580x895)" },
23 | { name: "node-240-32163", desc: "Call Logs 有数据 (375x827)" },
24 | ];
25 |
26 | // Layout statistics interface
27 | interface LayoutStats {
28 | total: number;
29 | flex: number;
30 | grid: number;
31 | flexRow: number;
32 | flexColumn: number;
33 | absolute: number;
34 | }
35 |
36 | // Helper: Count layouts recursively
37 | function countLayouts(node: SimplifiedNode, stats: LayoutStats): void {
38 | stats.total++;
39 |
40 | const display = node.cssStyles?.display;
41 | if (display === "flex") {
42 | stats.flex++;
43 | if (node.cssStyles?.flexDirection === "column") {
44 | stats.flexColumn++;
45 | } else {
46 | stats.flexRow++;
47 | }
48 | } else if (display === "grid") {
49 | stats.grid++;
50 | } else if (node.cssStyles?.position === "absolute") {
51 | stats.absolute++;
52 | }
53 |
54 | if (node.children) {
55 | for (const child of node.children) {
56 | countLayouts(child, stats);
57 | }
58 | }
59 | }
60 |
61 | // Helper: Find nodes by layout type
62 | function findLayoutNodes(
63 | node: SimplifiedNode,
64 | layoutType: "flex" | "grid",
65 | results: SimplifiedNode[] = [],
66 | ): SimplifiedNode[] {
67 | if (node.cssStyles?.display === layoutType) {
68 | results.push(node);
69 | }
70 |
71 | if (node.children) {
72 | for (const child of node.children) {
73 | findLayoutNodes(child, layoutType, results);
74 | }
75 | }
76 |
77 | return results;
78 | }
79 |
80 | // Helper: Load raw fixture data
81 | function loadFixture(name: string): unknown {
82 | const filePath = path.join(fixturesDir, `${name}.json`);
83 | return JSON.parse(fs.readFileSync(filePath, "utf-8"));
84 | }
85 |
86 | // Helper: Load expected output if exists
87 | function loadExpectedOutput(name: string): unknown | null {
88 | const filePath = path.join(expectedDir, `${name}-optimized.json`);
89 | if (fs.existsSync(filePath)) {
90 | return JSON.parse(fs.readFileSync(filePath, "utf-8"));
91 | }
92 | return null;
93 | }
94 |
95 | describe("Layout Optimization Integration", () => {
96 | TEST_FILES.forEach(({ name, desc }) => {
97 | describe(`${name} (${desc})`, () => {
98 | let rawData: unknown;
99 | let result: ReturnType<typeof parseFigmaResponse>;
100 | let originalSize: number;
101 | let optimizedSize: number;
102 | let stats: LayoutStats;
103 |
104 | beforeAll(() => {
105 | const filePath = path.join(fixturesDir, `${name}.json`);
106 | if (!fs.existsSync(filePath)) {
107 | throw new Error(`Test fixture not found: ${name}.json`);
108 | }
109 |
110 | rawData = loadFixture(name);
111 | originalSize = fs.statSync(filePath).size;
112 |
113 | result = parseFigmaResponse(rawData as Parameters<typeof parseFigmaResponse>[0]);
114 | optimizedSize = Buffer.byteLength(JSON.stringify(result));
115 |
116 | // Calculate layout stats
117 | stats = {
118 | total: 0,
119 | flex: 0,
120 | grid: 0,
121 | flexRow: 0,
122 | flexColumn: 0,
123 | absolute: 0,
124 | };
125 | for (const node of result.nodes) {
126 | countLayouts(node, stats);
127 | }
128 | });
129 |
130 | describe("Data Compression", () => {
131 | it("should achieve significant data compression", () => {
132 | const compressionRate = (1 - optimizedSize / originalSize) * 100;
133 | // Should achieve at least 50% compression
134 | expect(compressionRate).toBeGreaterThan(50);
135 | });
136 |
137 | it("should reduce file size to under 100KB for typical nodes", () => {
138 | // Optimized output should be reasonable size
139 | expect(optimizedSize).toBeLessThan(100 * 1024);
140 | });
141 | });
142 |
143 | describe("Layout Detection", () => {
144 | it("should detect layout types (not all absolute)", () => {
145 | // Should have some flex or grid layouts
146 | expect(stats.flex + stats.grid).toBeGreaterThan(0);
147 | });
148 |
149 | it("should not have excessive absolute positioning", () => {
150 | // Absolute positioning should not dominate in most cases
151 | // Some fixtures may have higher absolute ratios due to their design
152 | const absoluteRatio = stats.absolute / stats.total;
153 | // Allow up to 90% for complex layouts, but ensure some semantic layouts exist
154 | expect(absoluteRatio).toBeLessThan(0.9);
155 | });
156 |
157 | it("should have balanced flex row/column distribution", () => {
158 | // At least some flex should be detected
159 | expect(stats.flex).toBeGreaterThan(0);
160 | });
161 | });
162 |
163 | describe("Flex Layout Properties", () => {
164 | it("should have valid flex properties when flex is detected", () => {
165 | const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
166 |
167 | flexNodes.forEach((node) => {
168 | expect(node.cssStyles?.display).toBe("flex");
169 | // Direction should be defined
170 | expect(["row", "column", undefined]).toContain(node.cssStyles?.flexDirection);
171 | });
172 | });
173 |
174 | it("should have gap property for flex containers with spacing", () => {
175 | const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
176 |
177 | // At least some flex containers should have gap
178 | const flexWithGap = flexNodes.filter((n) => n.cssStyles?.gap);
179 | if (flexNodes.length > 2) {
180 | expect(flexWithGap.length).toBeGreaterThan(0);
181 | }
182 | });
183 | });
184 |
185 | describe("Grid Layout Properties", () => {
186 | it("should have valid grid properties when grid is detected", () => {
187 | const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
188 |
189 | gridNodes.forEach((node) => {
190 | expect(node.cssStyles?.display).toBe("grid");
191 | // Grid must have gridTemplateColumns
192 | expect(node.cssStyles?.gridTemplateColumns).toBeDefined();
193 | });
194 | });
195 |
196 | it("should have at least 4 children for grid containers", () => {
197 | const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
198 |
199 | gridNodes.forEach((node) => {
200 | expect(node.children?.length).toBeGreaterThanOrEqual(4);
201 | });
202 | });
203 |
204 | it("should have valid gridTemplateColumns format", () => {
205 | const gridNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "grid"));
206 |
207 | gridNodes.forEach((node) => {
208 | const columns = node.cssStyles?.gridTemplateColumns;
209 | if (columns) {
210 | // Should be space-separated pixel values or repeat()
211 | expect(columns).toMatch(/^(\d+px\s*)+$|^repeat\(/);
212 | }
213 | });
214 | });
215 | });
216 |
217 | describe("Child Style Cleanup", () => {
218 | it("should clean position:absolute from flex children", () => {
219 | const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
220 |
221 | flexNodes.forEach((parent) => {
222 | parent.children?.forEach((child) => {
223 | // Non-overlapping children should not have position:absolute
224 | if (!hasOverlapWithSiblings(child, parent.children || [])) {
225 | expect(child.cssStyles?.position).not.toBe("absolute");
226 | }
227 | });
228 | });
229 | });
230 |
231 | it("should clean left/top from flex children", () => {
232 | const flexNodes = result.nodes.flatMap((n) => findLayoutNodes(n, "flex"));
233 |
234 | flexNodes.forEach((parent) => {
235 | parent.children?.forEach((child) => {
236 | if (!hasOverlapWithSiblings(child, parent.children || [])) {
237 | expect(child.cssStyles?.left).toBeUndefined();
238 | expect(child.cssStyles?.top).toBeUndefined();
239 | }
240 | });
241 | });
242 | });
243 | });
244 |
245 | describe("Output Structure", () => {
246 | it("should have correct response structure", () => {
247 | expect(result).toHaveProperty("name");
248 | expect(result).toHaveProperty("nodes");
249 | expect(Array.isArray(result.nodes)).toBe(true);
250 | });
251 |
252 | it("should preserve node hierarchy", () => {
253 | expect(result.nodes.length).toBeGreaterThan(0);
254 | const rootNode = result.nodes[0];
255 | expect(rootNode).toHaveProperty("id");
256 | expect(rootNode).toHaveProperty("name");
257 | expect(rootNode).toHaveProperty("type");
258 | });
259 | });
260 |
261 | describe("Snapshot Comparison", () => {
262 | it("should match expected output structure", () => {
263 | const expected = loadExpectedOutput(name);
264 | if (expected) {
265 | // Compare node count
266 | expect(result.nodes.length).toBe((expected as { nodes: unknown[] }).nodes.length);
267 | }
268 | });
269 |
270 | it("should produce consistent layout stats", () => {
271 | // Use inline snapshot for layout stats
272 | expect({
273 | total: stats.total,
274 | flexRatio: Math.round((stats.flex / stats.total) * 100),
275 | gridRatio: Math.round((stats.grid / stats.total) * 100),
276 | absoluteRatio: Math.round((stats.absolute / stats.total) * 100),
277 | }).toMatchSnapshot();
278 | });
279 | });
280 | });
281 | });
282 | });
283 |
284 | // Helper: Check if a node overlaps with its siblings
285 | function hasOverlapWithSiblings(node: SimplifiedNode, siblings: SimplifiedNode[]): boolean {
286 | const nodeRect = extractRect(node);
287 | if (!nodeRect) return false;
288 |
289 | return siblings.some((sibling) => {
290 | if (sibling.id === node.id) return false;
291 | const siblingRect = extractRect(sibling);
292 | if (!siblingRect) return false;
293 |
294 | return calculateIoU(nodeRect, siblingRect) > 0.1;
295 | });
296 | }
297 |
298 | interface Rect {
299 | x: number;
300 | y: number;
301 | width: number;
302 | height: number;
303 | }
304 |
305 | function extractRect(node: SimplifiedNode): Rect | null {
306 | const styles = node.cssStyles;
307 | if (!styles?.width || !styles?.height) return null;
308 |
309 | return {
310 | x: parseFloat(styles.left || "0"),
311 | y: parseFloat(styles.top || "0"),
312 | width: parseFloat(styles.width),
313 | height: parseFloat(styles.height),
314 | };
315 | }
316 |
317 | function calculateIoU(a: Rect, b: Rect): number {
318 | const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
319 | const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
320 | const intersection = xOverlap * yOverlap;
321 |
322 | const areaA = a.width * a.height;
323 | const areaB = b.width * b.height;
324 | const union = areaA + areaB - intersection;
325 |
326 | return union > 0 ? intersection / union : 0;
327 | }
328 |
329 | // ==================== Optimization Idempotency Tests ====================
330 |
331 | describe("Layout Optimization Idempotency", () => {
332 | // Helper: Count occurrences of a key-value pair in object tree
333 | function countOccurrences(obj: unknown, key: string, value: string): number {
334 | let count = 0;
335 | function traverse(o: unknown): void {
336 | if (o && typeof o === "object") {
337 | if (Array.isArray(o)) {
338 | o.forEach(traverse);
339 | } else {
340 | const record = o as Record<string, unknown>;
341 | if (record[key] === value) count++;
342 | Object.values(record).forEach(traverse);
343 | }
344 | }
345 | }
346 | traverse(obj);
347 | return count;
348 | }
349 |
350 | // Helper: Count nodes with a specific property
351 | function countProperty(obj: unknown, prop: string): number {
352 | let count = 0;
353 | function traverse(o: unknown): void {
354 | if (o && typeof o === "object") {
355 | if (Array.isArray(o)) {
356 | o.forEach(traverse);
357 | } else {
358 | const record = o as Record<string, unknown>;
359 | if (prop in record) count++;
360 | Object.values(record).forEach(traverse);
361 | }
362 | }
363 | }
364 | traverse(obj);
365 | return count;
366 | }
367 |
368 | TEST_FILES.forEach(({ name, desc }) => {
369 | describe(`${name} (${desc})`, () => {
370 | let optimizedData: SimplifiedDesign;
371 |
372 | beforeAll(() => {
373 | const filePath = path.join(expectedDir, `${name}-optimized.json`);
374 | optimizedData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
375 | });
376 |
377 | it("should be idempotent (re-optimizing produces same result)", () => {
378 | // Optimize again
379 | const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
380 |
381 | // Compare key metrics
382 | const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
383 | const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
384 |
385 | const originalFlex = countOccurrences(optimizedData, "display", "flex");
386 | const reOptimizedFlex = countOccurrences(reOptimized, "display", "flex");
387 |
388 | const originalGrid = countOccurrences(optimizedData, "display", "grid");
389 | const reOptimizedGrid = countOccurrences(reOptimized, "display", "grid");
390 |
391 | // Re-optimization should not change layout counts significantly
392 | // (small differences possible due to background merging on first pass)
393 | expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
394 | expect(reOptimizedFlex).toBeGreaterThanOrEqual(originalFlex);
395 | expect(reOptimizedGrid).toBeGreaterThanOrEqual(originalGrid);
396 | });
397 |
398 | it("should maintain or reduce absolute positioning count", () => {
399 | const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
400 |
401 | const originalAbsolute = countOccurrences(optimizedData, "position", "absolute");
402 | const reOptimizedAbsolute = countOccurrences(reOptimized, "position", "absolute");
403 |
404 | // Absolute count should not increase
405 | expect(reOptimizedAbsolute).toBeLessThanOrEqual(originalAbsolute);
406 | });
407 |
408 | it("should maintain or reduce left/top property count", () => {
409 | const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
410 |
411 | const originalLeft = countProperty(optimizedData, "left");
412 | const reOptimizedLeft = countProperty(reOptimized, "left");
413 |
414 | const originalTop = countProperty(optimizedData, "top");
415 | const reOptimizedTop = countProperty(reOptimized, "top");
416 |
417 | expect(reOptimizedLeft).toBeLessThanOrEqual(originalLeft);
418 | expect(reOptimizedTop).toBeLessThanOrEqual(originalTop);
419 | });
420 |
421 | it("should produce stable output on second re-optimization", () => {
422 | // First re-optimization
423 | const firstPass = LayoutOptimizer.optimizeDesign(optimizedData);
424 | // Second re-optimization
425 | const secondPass = LayoutOptimizer.optimizeDesign(firstPass);
426 |
427 | // After two passes, results should be identical (stable)
428 | const firstPassJson = JSON.stringify(firstPass);
429 | const secondPassJson = JSON.stringify(secondPass);
430 |
431 | expect(secondPassJson).toBe(firstPassJson);
432 | });
433 |
434 | it("should have expected optimization metrics", () => {
435 | const reOptimized = LayoutOptimizer.optimizeDesign(optimizedData);
436 |
437 | expect({
438 | absoluteCount: countOccurrences(reOptimized, "position", "absolute"),
439 | flexCount: countOccurrences(reOptimized, "display", "flex"),
440 | gridCount: countOccurrences(reOptimized, "display", "grid"),
441 | paddingCount: countProperty(reOptimized, "padding"),
442 | }).toMatchSnapshot();
443 | });
444 | });
445 | });
446 | });
447 |
```
--------------------------------------------------------------------------------
/tests/unit/resources/figma-resources.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Figma Resources Unit Tests
3 | *
4 | * Tests the resource handlers that extract lightweight data from Figma files.
5 | */
6 |
7 | import { describe, it, expect, vi } from "vitest";
8 | import {
9 | getFileMetadata,
10 | getStyleTokens,
11 | getComponentList,
12 | getAssetList,
13 | createFileMetadataTemplate,
14 | createStylesTemplate,
15 | createComponentsTemplate,
16 | createAssetsTemplate,
17 | FIGMA_MCP_HELP,
18 | } from "~/resources/figma-resources.js";
19 | import type { SimplifiedDesign, SimplifiedNode } from "~/types/index.js";
20 | import type { FigmaService } from "~/services/figma.js";
21 |
22 | // ==================== Mock Types ====================
23 |
24 | type MockFigmaService = Pick<
25 | FigmaService,
26 | "getFile" | "getNode" | "getImages" | "getImageFills" | "getRateLimitInfo"
27 | >;
28 |
29 | // ==================== Mock Data ====================
30 |
31 | const createMockNode = (overrides: Partial<SimplifiedNode> = {}): SimplifiedNode => ({
32 | id: "node-1",
33 | name: "Test Node",
34 | type: "FRAME",
35 | ...overrides,
36 | });
37 |
38 | const createMockDesign = (overrides: Partial<SimplifiedDesign> = {}): SimplifiedDesign => ({
39 | name: "Test Design",
40 | lastModified: "2024-01-15T10:30:00Z",
41 | thumbnailUrl: "https://figma.com/thumbnail.png",
42 | nodes: [],
43 | ...overrides,
44 | });
45 |
46 | // Mock FigmaService
47 | const createMockFigmaService = (design: SimplifiedDesign): MockFigmaService => ({
48 | getFile: vi.fn().mockResolvedValue(design),
49 | getNode: vi.fn().mockResolvedValue(design),
50 | getImages: vi.fn().mockResolvedValue([]),
51 | getImageFills: vi.fn().mockResolvedValue([]),
52 | getRateLimitInfo: vi.fn().mockReturnValue(null),
53 | });
54 |
55 | // ==================== Tests ====================
56 |
57 | describe("Figma Resources", () => {
58 | describe("getFileMetadata", () => {
59 | it("should extract basic file metadata", async () => {
60 | const mockDesign = createMockDesign({
61 | name: "My Design File",
62 | lastModified: "2024-03-20T15:00:00Z",
63 | nodes: [
64 | createMockNode({
65 | id: "page-1",
66 | name: "Page 1",
67 | type: "CANVAS",
68 | children: [createMockNode(), createMockNode()],
69 | }),
70 | createMockNode({
71 | id: "page-2",
72 | name: "Page 2",
73 | type: "CANVAS",
74 | children: [createMockNode()],
75 | }),
76 | ],
77 | });
78 |
79 | const mockService = createMockFigmaService(mockDesign);
80 | const metadata = await getFileMetadata(mockService as FigmaService, "test-file-key");
81 |
82 | expect(metadata.name).toBe("My Design File");
83 | expect(metadata.lastModified).toBe("2024-03-20T15:00:00Z");
84 | expect(metadata.pages).toHaveLength(2);
85 | expect(metadata.pages[0]).toEqual({
86 | id: "page-1",
87 | name: "Page 1",
88 | childCount: 2,
89 | });
90 | expect(metadata.pages[1]).toEqual({
91 | id: "page-2",
92 | name: "Page 2",
93 | childCount: 1,
94 | });
95 | });
96 |
97 | it("should call getFile with depth 1", async () => {
98 | const mockDesign = createMockDesign();
99 | const mockService = createMockFigmaService(mockDesign);
100 |
101 | await getFileMetadata(mockService as FigmaService, "test-key");
102 |
103 | expect(mockService.getFile).toHaveBeenCalledWith("test-key", 1);
104 | });
105 |
106 | it("should handle files with no pages", async () => {
107 | const mockDesign = createMockDesign({ nodes: [] });
108 | const mockService = createMockFigmaService(mockDesign);
109 |
110 | const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
111 |
112 | expect(metadata.pages).toHaveLength(0);
113 | });
114 |
115 | it("should filter out non-CANVAS nodes from pages", async () => {
116 | const mockDesign = createMockDesign({
117 | nodes: [
118 | createMockNode({ id: "page-1", name: "Page", type: "CANVAS" }),
119 | createMockNode({ id: "frame-1", name: "Frame", type: "FRAME" }),
120 | createMockNode({ id: "page-2", name: "Page 2", type: "CANVAS" }),
121 | ],
122 | });
123 | const mockService = createMockFigmaService(mockDesign);
124 |
125 | const metadata = await getFileMetadata(mockService as FigmaService, "test-key");
126 |
127 | expect(metadata.pages).toHaveLength(2);
128 | expect(metadata.pages.every((p) => p.name.startsWith("Page"))).toBe(true);
129 | });
130 | });
131 |
132 | describe("getStyleTokens", () => {
133 | it("should extract colors from node CSS", async () => {
134 | const mockDesign = createMockDesign({
135 | nodes: [
136 | createMockNode({
137 | name: "Primary Button",
138 | cssStyles: {
139 | backgroundColor: "#24C790",
140 | color: "#FFFFFF",
141 | },
142 | }),
143 | ],
144 | });
145 | const mockService = createMockFigmaService(mockDesign);
146 |
147 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
148 |
149 | expect(styles.colors.length).toBeGreaterThan(0);
150 | expect(styles.colors.some((c) => c.hex === "#24C790")).toBe(true);
151 | });
152 |
153 | it("should extract typography from node CSS", async () => {
154 | const mockDesign = createMockDesign({
155 | nodes: [
156 | createMockNode({
157 | name: "Heading",
158 | cssStyles: {
159 | fontFamily: "Inter",
160 | fontSize: "24px",
161 | fontWeight: "700",
162 | lineHeight: "32px",
163 | },
164 | }),
165 | ],
166 | });
167 | const mockService = createMockFigmaService(mockDesign);
168 |
169 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
170 |
171 | expect(styles.typography.length).toBeGreaterThan(0);
172 | expect(styles.typography[0]).toMatchObject({
173 | fontFamily: "Inter",
174 | fontSize: 24,
175 | fontWeight: 700,
176 | });
177 | });
178 |
179 | it("should extract shadow effects", async () => {
180 | const mockDesign = createMockDesign({
181 | nodes: [
182 | createMockNode({
183 | name: "Card",
184 | cssStyles: {
185 | boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
186 | },
187 | }),
188 | ],
189 | });
190 | const mockService = createMockFigmaService(mockDesign);
191 |
192 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
193 |
194 | expect(styles.effects.length).toBeGreaterThan(0);
195 | expect(styles.effects[0].type).toBe("shadow");
196 | });
197 |
198 | it("should deduplicate colors", async () => {
199 | const mockDesign = createMockDesign({
200 | nodes: [
201 | createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
202 | createMockNode({ cssStyles: { backgroundColor: "#FF0000" } }),
203 | createMockNode({ cssStyles: { backgroundColor: "#00FF00" } }),
204 | ],
205 | });
206 | const mockService = createMockFigmaService(mockDesign);
207 |
208 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
209 |
210 | const redColors = styles.colors.filter((c) => c.hex === "#FF0000");
211 | expect(redColors.length).toBe(1);
212 | });
213 |
214 | it("should limit results to avoid token bloat", async () => {
215 | // Create many nodes with unique colors
216 | const nodes = Array.from({ length: 50 }, (_, i) =>
217 | createMockNode({
218 | name: `Node ${i}`,
219 | cssStyles: { backgroundColor: `#${i.toString(16).padStart(6, "0")}` },
220 | }),
221 | );
222 | const mockDesign = createMockDesign({ nodes });
223 | const mockService = createMockFigmaService(mockDesign);
224 |
225 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
226 |
227 | expect(styles.colors.length).toBeLessThanOrEqual(20);
228 | });
229 |
230 | it("should recursively extract from children", async () => {
231 | const mockDesign = createMockDesign({
232 | nodes: [
233 | createMockNode({
234 | name: "Parent",
235 | children: [
236 | createMockNode({
237 | name: "Child",
238 | cssStyles: { backgroundColor: "#AABBCC" },
239 | }),
240 | ],
241 | }),
242 | ],
243 | });
244 | const mockService = createMockFigmaService(mockDesign);
245 |
246 | const styles = await getStyleTokens(mockService as FigmaService, "test-key");
247 |
248 | expect(styles.colors.some((c) => c.hex === "#AABBCC")).toBe(true);
249 | });
250 | });
251 |
252 | describe("getComponentList", () => {
253 | it("should find COMPONENT nodes", async () => {
254 | const mockDesign = createMockDesign({
255 | nodes: [
256 | createMockNode({
257 | id: "comp-1",
258 | name: "Button",
259 | type: "COMPONENT",
260 | }),
261 | ],
262 | });
263 | const mockService = createMockFigmaService(mockDesign);
264 |
265 | const components = await getComponentList(mockService as FigmaService, "test-key");
266 |
267 | expect(components).toHaveLength(1);
268 | expect(components[0]).toMatchObject({
269 | id: "comp-1",
270 | name: "Button",
271 | type: "COMPONENT",
272 | });
273 | });
274 |
275 | it("should find COMPONENT_SET nodes with variants", async () => {
276 | const mockDesign = createMockDesign({
277 | nodes: [
278 | createMockNode({
279 | id: "set-1",
280 | name: "Button",
281 | type: "COMPONENT_SET",
282 | children: [
283 | createMockNode({ name: "Primary" }),
284 | createMockNode({ name: "Secondary" }),
285 | createMockNode({ name: "Outline" }),
286 | ],
287 | }),
288 | ],
289 | });
290 | const mockService = createMockFigmaService(mockDesign);
291 |
292 | const components = await getComponentList(mockService as FigmaService, "test-key");
293 |
294 | expect(components).toHaveLength(1);
295 | expect(components[0].type).toBe("COMPONENT_SET");
296 | expect(components[0].variants).toEqual(["Primary", "Secondary", "Outline"]);
297 | });
298 |
299 | it("should limit variants to 5", async () => {
300 | const variants = Array.from({ length: 10 }, (_, i) =>
301 | createMockNode({ name: `Variant ${i}` }),
302 | );
303 | const mockDesign = createMockDesign({
304 | nodes: [
305 | createMockNode({
306 | id: "set-1",
307 | name: "Button",
308 | type: "COMPONENT_SET",
309 | children: variants,
310 | }),
311 | ],
312 | });
313 | const mockService = createMockFigmaService(mockDesign);
314 |
315 | const components = await getComponentList(mockService as FigmaService, "test-key");
316 |
317 | expect(components[0].variants).toHaveLength(5);
318 | });
319 |
320 | it("should find nested components", async () => {
321 | const mockDesign = createMockDesign({
322 | nodes: [
323 | createMockNode({
324 | name: "Page",
325 | type: "CANVAS",
326 | children: [
327 | createMockNode({
328 | name: "Components",
329 | type: "FRAME",
330 | children: [
331 | createMockNode({ id: "c1", name: "Button", type: "COMPONENT" }),
332 | createMockNode({ id: "c2", name: "Input", type: "COMPONENT" }),
333 | ],
334 | }),
335 | ],
336 | }),
337 | ],
338 | });
339 | const mockService = createMockFigmaService(mockDesign);
340 |
341 | const components = await getComponentList(mockService as FigmaService, "test-key");
342 |
343 | expect(components).toHaveLength(2);
344 | });
345 |
346 | it("should limit to 50 components", async () => {
347 | const nodes = Array.from({ length: 60 }, (_, i) =>
348 | createMockNode({
349 | id: `comp-${i}`,
350 | name: `Component ${i}`,
351 | type: "COMPONENT",
352 | }),
353 | );
354 | const mockDesign = createMockDesign({ nodes });
355 | const mockService = createMockFigmaService(mockDesign);
356 |
357 | const components = await getComponentList(mockService as FigmaService, "test-key");
358 |
359 | expect(components.length).toBeLessThanOrEqual(50);
360 | });
361 | });
362 |
363 | describe("getAssetList", () => {
364 | it("should find nodes with exportInfo", async () => {
365 | const mockDesign = createMockDesign({
366 | nodes: [
367 | createMockNode({
368 | id: "icon-1",
369 | name: "arrow-right",
370 | type: "VECTOR",
371 | exportInfo: { type: "IMAGE", format: "SVG" },
372 | }),
373 | ],
374 | });
375 | const mockService = createMockFigmaService(mockDesign);
376 |
377 | const assets = await getAssetList(mockService as FigmaService, "test-key");
378 |
379 | expect(assets).toHaveLength(1);
380 | expect(assets[0]).toMatchObject({
381 | nodeId: "icon-1",
382 | name: "arrow-right",
383 | type: "icon",
384 | exportFormats: ["SVG"],
385 | });
386 | });
387 |
388 | it("should identify icons by type VECTOR", async () => {
389 | const mockDesign = createMockDesign({
390 | nodes: [
391 | createMockNode({
392 | id: "icon-1",
393 | name: "small-icon",
394 | type: "VECTOR",
395 | exportInfo: { type: "IMAGE", format: "SVG" },
396 | }),
397 | ],
398 | });
399 | const mockService = createMockFigmaService(mockDesign);
400 |
401 | const assets = await getAssetList(mockService as FigmaService, "test-key");
402 |
403 | expect(assets[0].type).toBe("icon");
404 | });
405 |
406 | it("should identify large exports as vector type", async () => {
407 | const mockDesign = createMockDesign({
408 | nodes: [
409 | createMockNode({
410 | id: "illustration-1",
411 | name: "hero-image",
412 | type: "FRAME",
413 | exportInfo: { type: "IMAGE_GROUP", format: "SVG" },
414 | }),
415 | ],
416 | });
417 | const mockService = createMockFigmaService(mockDesign);
418 |
419 | const assets = await getAssetList(mockService as FigmaService, "test-key");
420 |
421 | expect(assets[0].type).toBe("vector");
422 | });
423 |
424 | it("should find nodes with image fills", async () => {
425 | const mockDesign = createMockDesign({
426 | nodes: [
427 | createMockNode({
428 | id: "img-1",
429 | name: "photo",
430 | type: "RECTANGLE",
431 | fills: [{ type: "IMAGE", imageRef: "img:abc123" }],
432 | }),
433 | ],
434 | });
435 | const mockService = createMockFigmaService(mockDesign);
436 |
437 | const assets = await getAssetList(mockService as FigmaService, "test-key");
438 |
439 | expect(assets).toHaveLength(1);
440 | expect(assets[0]).toMatchObject({
441 | nodeId: "img-1",
442 | name: "photo",
443 | type: "image",
444 | imageRef: "img:abc123",
445 | });
446 | });
447 |
448 | it("should find nested assets", async () => {
449 | const mockDesign = createMockDesign({
450 | nodes: [
451 | createMockNode({
452 | name: "Card",
453 | children: [
454 | createMockNode({
455 | id: "icon",
456 | name: "icon",
457 | type: "VECTOR",
458 | exportInfo: { type: "IMAGE", format: "SVG" },
459 | }),
460 | createMockNode({
461 | id: "image",
462 | name: "thumbnail",
463 | fills: [{ type: "IMAGE", imageRef: "img:xyz" }],
464 | }),
465 | ],
466 | }),
467 | ],
468 | });
469 | const mockService = createMockFigmaService(mockDesign);
470 |
471 | const assets = await getAssetList(mockService as FigmaService, "test-key");
472 |
473 | expect(assets).toHaveLength(2);
474 | });
475 |
476 | it("should limit to 100 assets", async () => {
477 | const nodes = Array.from({ length: 150 }, (_, i) =>
478 | createMockNode({
479 | id: `asset-${i}`,
480 | name: `Asset ${i}`,
481 | type: "VECTOR",
482 | exportInfo: { type: "IMAGE", format: "SVG" },
483 | }),
484 | );
485 | const mockDesign = createMockDesign({ nodes });
486 | const mockService = createMockFigmaService(mockDesign);
487 |
488 | const assets = await getAssetList(mockService as FigmaService, "test-key");
489 |
490 | expect(assets.length).toBeLessThanOrEqual(100);
491 | });
492 | });
493 |
494 | describe("Resource Templates", () => {
495 | it("should create file metadata template with correct URI pattern", () => {
496 | const template = createFileMetadataTemplate();
497 | expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}");
498 | });
499 |
500 | it("should create styles template with correct URI pattern", () => {
501 | const template = createStylesTemplate();
502 | expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/styles");
503 | });
504 |
505 | it("should create components template with correct URI pattern", () => {
506 | const template = createComponentsTemplate();
507 | expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/components");
508 | });
509 |
510 | it("should create assets template with correct URI pattern", () => {
511 | const template = createAssetsTemplate();
512 | expect(template.uriTemplate.toString()).toBe("figma://file/{fileKey}/assets");
513 | });
514 | });
515 |
516 | describe("Help Content", () => {
517 | it("should contain resource documentation", () => {
518 | expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}");
519 | expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/styles");
520 | expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/components");
521 | expect(FIGMA_MCP_HELP).toContain("figma://file/{fileKey}/assets");
522 | });
523 |
524 | it("should explain token costs", () => {
525 | expect(FIGMA_MCP_HELP).toContain("Token cost");
526 | });
527 |
528 | it("should explain how to get fileKey", () => {
529 | expect(FIGMA_MCP_HELP).toContain("fileKey");
530 | expect(FIGMA_MCP_HELP).toContain("figma.com");
531 | });
532 | });
533 | });
534 |
```