UNPKG

figma-to-react-mcp

Version:

Convert Figma designs to React components automatically. MCP server with GitHub, Figma, and Playwright integrations for seamless design-to-code workflow.

416 lines 15.4 kB
import axios from "axios"; import { Logger } from "../utils/logger.js"; export class FigmaIntegration { api; config; logger; cache = new Map(); cacheTTL = 5 * 60 * 1000; tokenCache = { colors: new Map(), fonts: new Map(), spacing: new Map(), borderRadius: new Map(), }; constructor(config) { this.config = config; this.logger = Logger.getInstance(); this.api = axios.create({ baseURL: "https://api.figma.com/v1", headers: { "X-Figma-Token": this.config.accessToken, "Content-Type": "application/json", }, timeout: 30000, maxRedirects: 3, }); this.api.interceptors.response.use((response) => { if (response.config.url) { this.setCache(response.config.url, response.data); } return response; }, (error) => { this.logger.error("Figma API request failed", { url: error.config?.url, status: error.response?.status, message: error.message, }); return Promise.reject(error); }); } setCache(key, data, ttl = this.cacheTTL) { this.cache.set(key, { data, timestamp: Date.now(), ttl, }); } getCache(key) { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > entry.ttl) { this.cache.delete(key); return null; } return entry.data; } async getFile(fileId) { try { const cacheKey = `/files/${fileId}`; const cached = this.getCache(cacheKey); if (cached) { this.logger.debug(`Using cached Figma file: ${fileId}`); return { success: true, data: cached }; } this.logger.info(`Fetching Figma file: ${fileId}`); const response = await this.api.get(cacheKey); const fileData = response.data; const figmaDesign = { id: fileId, name: fileData.name, lastModified: fileData.lastModified, thumbnailUrl: fileData.thumbnailUrl || "", version: fileData.version, }; const result = { design: figmaDesign, document: fileData.document, }; return { success: true, data: result, }; } catch (error) { this.logger.error(`Failed to fetch Figma file: ${fileId}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } async getFrame(fileId, nodeId) { try { this.logger.info(`Fetching Figma frame: ${fileId}/${nodeId}`); const fileResult = await this.getFile(fileId); if (!fileResult.success) { return fileResult; } const document = fileResult.data.document; const frameNode = this.findNodeById(document, nodeId); if (!frameNode) { return { success: false, error: `Frame with ID ${nodeId} not found`, }; } const frame = { id: frameNode.id, name: frameNode.name, width: frameNode.absoluteBoundingBox?.width || 0, height: frameNode.absoluteBoundingBox?.height || 0, x: frameNode.absoluteBoundingBox?.x || 0, y: frameNode.absoluteBoundingBox?.y || 0, backgroundColor: this.extractBackgroundColor(frameNode), children: frameNode.children ? frameNode.children.map((child) => this.convertToFigmaNode(child)) : [], }; return { success: true, data: frame, }; } catch (error) { this.logger.error(`Failed to fetch Figma frame: ${fileId}/${nodeId}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } async getImages(fileId, nodeIds) { try { this.logger.info(`Fetching Figma images: ${fileId}`); const params = new URLSearchParams({ ids: nodeIds.join(","), format: "png", scale: "2", }); const response = await this.api.get(`/images/${fileId}?${params}`); const imageData = response.data; if (imageData.err) { return { success: false, error: imageData.err, }; } return { success: true, data: imageData.images, }; } catch (error) { this.logger.error(`Failed to fetch Figma images: ${fileId}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } async analyzeDesignTokens(fileId) { try { const cacheKey = `tokens:${fileId}`; const cached = this.getCache(cacheKey); if (cached) { this.logger.debug(`Using cached design tokens: ${fileId}`); return { success: true, data: cached }; } this.logger.info(`Analyzing design tokens for file: ${fileId}`); const fileResult = await this.getFile(fileId); if (!fileResult.success) { return fileResult; } const document = fileResult.data.document; const designTokens = this.extractDesignTokensOptimized(document); this.setCache(cacheKey, designTokens, this.cacheTTL * 2); return { success: true, data: designTokens, }; } catch (error) { this.logger.error(`Failed to analyze design tokens: ${fileId}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } async extractComponents(fileId) { try { const cacheKey = `components:${fileId}`; const cached = this.getCache(cacheKey); if (cached) { this.logger.debug(`Using cached components: ${fileId}`); return { success: true, data: cached }; } this.logger.info(`Extracting components from file: ${fileId}`); const fileResult = await this.getFile(fileId); if (!fileResult.success) { return fileResult; } const document = fileResult.data.document; const components = this.findComponentsOptimized(document); this.setCache(cacheKey, components, this.cacheTTL * 2); return { success: true, data: components, }; } catch (error) { this.logger.error(`Failed to extract components: ${fileId}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } findNodeById(node, id) { if (node.id === id) { return node; } if (node.children) { for (const child of node.children) { const found = this.findNodeById(child, id); if (found) { return found; } } } return null; } extractBackgroundColor(node) { if (node.fills && node.fills.length > 0) { const solidFill = node.fills.find((fill) => fill.type === "SOLID"); if (solidFill && solidFill.color) { const { r, g, b, a = 1 } = solidFill.color; return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`; } } return "#ffffff"; } extractDesignTokensOptimized(document) { this.tokenCache = { colors: new Map(), fonts: new Map(), spacing: new Map(), borderRadius: new Map(), }; const nodesToProcess = [document]; while (nodesToProcess.length > 0) { const node = nodesToProcess.pop(); this.processNodeForTokens(node); if (node.children) { nodesToProcess.push(...node.children); } } this.extractStyleTokens(document); return { colors: Array.from(this.tokenCache.colors.values()), fonts: Array.from(this.tokenCache.fonts.values()), spacing: Array.from(this.tokenCache.spacing.values()).sort((a, b) => a - b), borderRadius: Array.from(this.tokenCache.borderRadius.values()).sort((a, b) => a - b), shadows: this.extractShadows(document), gradients: this.extractGradients(document), }; } extractStyleTokens(document) { if (document.styles) { Object.values(document.styles).forEach((style) => { if (style.styleType === "FILL" && style.fills) { style.fills.forEach((fill) => { if (fill.type === "SOLID" && fill.color) { const { r, g, b, a = 1 } = fill.color; const colorKey = `${r}-${g}-${b}-${a}`; const colorValue = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`; this.tokenCache.colors.set(colorKey, colorValue); } }); } }); } } extractShadows(document) { const shadows = []; const nodesToProcess = [document]; while (nodesToProcess.length > 0) { const node = nodesToProcess.pop(); if (node.effects) { node.effects.forEach((effect) => { if (effect.type === "DROP_SHADOW" && effect.visible) { const { offset, radius, color } = effect; const shadowValue = `${offset?.x || 0}px ${offset?.y || 0}px ${radius || 0}px rgba(${Math.round((color?.r || 0) * 255)}, ${Math.round((color?.g || 0) * 255)}, ${Math.round((color?.b || 0) * 255)}, ${color?.a || 1})`; if (!shadows.includes(shadowValue)) { shadows.push(shadowValue); } } }); } if (node.children) { nodesToProcess.push(...node.children); } } return shadows; } extractGradients(document) { const gradients = []; const nodesToProcess = [document]; while (nodesToProcess.length > 0) { const node = nodesToProcess.pop(); if (node.fills) { node.fills.forEach((fill) => { if (fill.type === "GRADIENT_LINEAR" || fill.type === "GRADIENT_RADIAL") { const gradientValue = this.convertGradientToCSS(fill); if (gradientValue && !gradients.includes(gradientValue)) { gradients.push(gradientValue); } } }); } if (node.children) { nodesToProcess.push(...node.children); } } return gradients; } convertGradientToCSS(fill) { if (!fill.gradientStops || fill.gradientStops.length < 2) { return null; } const stops = fill.gradientStops .map((stop) => { const { r, g, b, a = 1 } = stop.color; const color = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`; return `${color} ${Math.round(stop.position * 100)}%`; }) .join(", "); if (fill.type === "GRADIENT_LINEAR") { return `linear-gradient(90deg, ${stops})`; } else if (fill.type === "GRADIENT_RADIAL") { return `radial-gradient(circle, ${stops})`; } return null; } processNodeForTokens(node) { if (node.fills) { node.fills.forEach((fill) => { if (fill.type === "SOLID" && fill.color) { const { r, g, b, a = 1 } = fill.color; const colorKey = `${r}-${g}-${b}-${a}`; const colorValue = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`; this.tokenCache.colors.set(colorKey, colorValue); } }); } if (node.style?.fontFamily) { this.tokenCache.fonts.set(node.style.fontFamily, node.style.fontFamily); } if (node.absoluteBoundingBox) { const width = Math.round(node.absoluteBoundingBox.width); const height = Math.round(node.absoluteBoundingBox.height); if (width > 0) this.tokenCache.spacing.set(`w-${width}`, width); if (height > 0) this.tokenCache.spacing.set(`h-${height}`, height); } if (typeof node.cornerRadius === "number") { this.tokenCache.borderRadius.set(`br-${node.cornerRadius}`, node.cornerRadius); } } findComponentsOptimized(document) { const components = []; const nodesToProcess = [document]; while (nodesToProcess.length > 0) { const node = nodesToProcess.pop(); if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") { components.push(this.convertToFigmaNode(node)); } if (node.children) { nodesToProcess.push(...node.children); } } return components; } convertToFigmaNode(node) { return { id: node.id, name: node.name, type: node.type, width: node.absoluteBoundingBox?.width || 0, height: node.absoluteBoundingBox?.height || 0, x: node.absoluteBoundingBox?.x || 0, y: node.absoluteBoundingBox?.y || 0, fills: node.fills, strokes: node.strokes, effects: node.effects, cornerRadius: node.cornerRadius, children: node.children ? node.children.map((child) => this.convertToFigmaNode(child)) : undefined, }; } clearCache() { this.cache.clear(); this.tokenCache = { colors: new Map(), fonts: new Map(), spacing: new Map(), borderRadius: new Map(), }; } } //# sourceMappingURL=figma.js.map