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
JavaScript
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