/** * Figma Response Simplifier */ import type { FigmaNode, FigmaFileResponse, FigmaNodeResponse, FigmaColor, FigmaPaint, FigmaEffect, FigmaTextStyle, SimplifiedDesign, SimplifiedNode, SimplifiedLayout, SimplifiedStroke, SimplifiedEffects, SimplifiedTextStyle, SimplifiedFill, } from './types.ts'; interface GlobalVars { styles: Record; } let varCounter = 0; function generateVarId(prefix: string): string { return `${prefix}_${String(++varCounter).padStart(3, '0')}`; } function findOrCreateVar(globalVars: GlobalVars, value: unknown, prefix: string): string { const existing = Object.entries(globalVars.styles).find( ([, v]) => JSON.stringify(v) === JSON.stringify(value) ); if (existing) { return existing[0]; } const id = generateVarId(prefix); globalVars.styles[id] = value; return id; } function isVisible(item: { visible?: boolean }): boolean { return item.visible !== false; } function round(num: number): number { return Math.round(num * 100) / 100; } function convertColor(rgba: FigmaColor, opacity = 1): { hex: string; opacity: number } { const r = Math.round(rgba.r * 255); const g = Math.round(rgba.g * 255); const b = Math.round(rgba.b * 255); const a = round(rgba.a * opacity); const hex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); return { hex, opacity: a }; } function formatRgba(rgba: FigmaColor, opacity = 1): string { const r = Math.round(rgba.r * 255); const g = Math.round(rgba.g * 255); const b = Math.round(rgba.b * 255); const a = round(rgba.a * opacity); return `rgba(${r}, ${g}, ${b}, ${a})`; } function formatCSSShorthand(values: { top: number; right: number; bottom: number; left: number; }): string { const { top, right, bottom, left } = values; if (top === right && right === bottom && bottom === left) { return `${top}px`; } if (top === bottom && right === left) { return `${top}px ${right}px`; } return `${top}px ${right}px ${bottom}px ${left}px`; } function simplifyFill(fill: FigmaPaint): SimplifiedFill { if (fill.type === 'IMAGE') { return { type: 'IMAGE', imageRef: fill.imageRef, scaleMode: fill.scaleMode, }; } if (fill.type === 'SOLID' && fill.color) { const { hex, opacity } = convertColor(fill.color, fill.opacity); return opacity === 1 ? hex : formatRgba(fill.color, opacity); } if (fill.type?.startsWith('GRADIENT_')) { return { type: fill.type, gradientHandlePositions: fill.gradientHandlePositions, gradientStops: fill.gradientStops?.map((stop) => ({ position: stop.position, color: convertColor(stop.color), })), }; } return { type: fill.type }; } function simplifyTextStyle(style: FigmaTextStyle): SimplifiedTextStyle { const simplified: SimplifiedTextStyle = {}; if (style.fontFamily) simplified.fontFamily = style.fontFamily; if (style.fontWeight) simplified.fontWeight = style.fontWeight; if (style.fontSize) simplified.fontSize = style.fontSize; if (style.lineHeightPx && style.fontSize) { simplified.lineHeight = `${(style.lineHeightPx / style.fontSize).toFixed(2)}em`; } if (style.letterSpacing && style.letterSpacing !== 0 && style.fontSize) { simplified.letterSpacing = `${((style.letterSpacing / style.fontSize) * 100).toFixed(1)}%`; } if (style.textCase) simplified.textCase = style.textCase; if (style.textAlignHorizontal) simplified.textAlignHorizontal = style.textAlignHorizontal; if (style.textAlignVertical) simplified.textAlignVertical = style.textAlignVertical; return simplified; } function simplifyStrokes(node: FigmaNode): SimplifiedStroke { const result: SimplifiedStroke = { colors: [] }; if (node.strokes?.length) { result.colors = node.strokes.filter(isVisible).map(simplifyFill); } if (typeof node.strokeWeight === 'number' && node.strokeWeight > 0) { result.strokeWeight = `${node.strokeWeight}px`; } if (node.individualStrokeWeights) { const { top, right, bottom, left } = node.individualStrokeWeights; result.strokeWeight = formatCSSShorthand({ top, right, bottom, left }); } if (node.strokeDashes?.length) { result.strokeDashes = node.strokeDashes; } return result; } function simplifyEffects(node: FigmaNode): SimplifiedEffects { if (!node.effects?.length) return {}; const visible = node.effects.filter((e) => e.visible); const result: SimplifiedEffects = {}; const dropShadows = visible .filter((e): e is FigmaEffect & { type: 'DROP_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'DROP_SHADOW') .map((e) => `${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`); const innerShadows = visible .filter((e): e is FigmaEffect & { type: 'INNER_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'INNER_SHADOW') .map((e) => `inset ${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`); const boxShadow = [...dropShadows, ...innerShadows].join(', '); const layerBlurs = visible .filter((e): e is FigmaEffect & { type: 'LAYER_BLUR' } => e.type === 'LAYER_BLUR') .map((e) => `blur(${e.radius}px)`); const bgBlurs = visible .filter((e): e is FigmaEffect & { type: 'BACKGROUND_BLUR' } => e.type === 'BACKGROUND_BLUR') .map((e) => `blur(${e.radius}px)`); if (boxShadow) { if (node.type === 'TEXT') { result.textShadow = boxShadow; } else { result.boxShadow = boxShadow; } } if (layerBlurs.length) result.filter = layerBlurs.join(' '); if (bgBlurs.length) result.backdropFilter = bgBlurs.join(' '); return result; } function convertAlign(align?: string): string | undefined { const map: Record = { MIN: 'flex-start', MAX: 'flex-end', CENTER: 'center', SPACE_BETWEEN: 'space-between', BASELINE: 'baseline', }; return map[align || '']; } function convertSelfAlign(align?: string): string | undefined { const map: Record = { MIN: 'flex-start', MAX: 'flex-end', CENTER: 'center', STRETCH: 'stretch', }; return map[align || '']; } function convertSizing(sizing?: string): 'fixed' | 'fill' | 'hug' | undefined { const map: Record = { FIXED: 'fixed', FILL: 'fill', HUG: 'hug', }; return map[sizing || '']; } function simplifyLayout(node: FigmaNode, parent?: FigmaNode): SimplifiedLayout { const layout: SimplifiedLayout = { mode: 'none' }; if (node.layoutMode && node.layoutMode !== 'NONE') { layout.mode = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'; layout.justifyContent = convertAlign(node.primaryAxisAlignItems); layout.alignItems = convertAlign(node.counterAxisAlignItems); layout.alignSelf = convertSelfAlign(node.layoutAlign); if (node.layoutWrap === 'WRAP') { layout.wrap = true; } if (node.itemSpacing) { layout.gap = `${node.itemSpacing}px`; } if (node.paddingTop || node.paddingRight || node.paddingBottom || node.paddingLeft) { layout.padding = formatCSSShorthand({ top: node.paddingTop || 0, right: node.paddingRight || 0, bottom: node.paddingBottom || 0, left: node.paddingLeft || 0, }); } const overflow: ('x' | 'y')[] = []; if (node.overflowDirection?.includes('HORIZONTAL')) overflow.push('x'); if (node.overflowDirection?.includes('VERTICAL')) overflow.push('y'); if (overflow.length) layout.overflowScroll = overflow; } if (node.layoutSizingHorizontal || node.layoutSizingVertical) { layout.sizing = {}; if (node.layoutSizingHorizontal) { layout.sizing.horizontal = convertSizing(node.layoutSizingHorizontal); } if (node.layoutSizingVertical) { layout.sizing.vertical = convertSizing(node.layoutSizingVertical); } } const parentHasLayout = parent?.layoutMode && parent.layoutMode !== 'NONE'; if (node.layoutPositioning === 'ABSOLUTE') { layout.position = 'absolute'; } if (node.absoluteBoundingBox && parent?.absoluteBoundingBox) { const isAbsolute = node.layoutPositioning === 'ABSOLUTE'; const isOutsideAutoLayout = parentHasLayout && node.layoutPositioning === 'ABSOLUTE'; if (isAbsolute || isOutsideAutoLayout) { layout.locationRelativeToParent = { x: round(node.absoluteBoundingBox.x - parent.absoluteBoundingBox.x), y: round(node.absoluteBoundingBox.y - parent.absoluteBoundingBox.y), }; } } if (node.absoluteBoundingBox) { const dims: { width?: number; height?: number; aspectRatio?: number } = {}; const isRow = layout.mode === 'row'; const isCol = layout.mode === 'column'; const includeWidth = !isRow || node.layoutSizingHorizontal === 'FIXED'; const includeHeight = !isCol || node.layoutSizingVertical === 'FIXED'; if (includeWidth && node.absoluteBoundingBox.width) { dims.width = round(node.absoluteBoundingBox.width); } if (includeHeight && node.absoluteBoundingBox.height) { dims.height = round(node.absoluteBoundingBox.height); } if (node.preserveRatio && dims.width && dims.height) { dims.aspectRatio = round(dims.width / dims.height); } if (Object.keys(dims).length) { layout.dimensions = dims; } } return layout; } function simplifyNode(node: FigmaNode, globalVars: GlobalVars, parent?: FigmaNode): SimplifiedNode | null { if (!node) return null; const simplified: SimplifiedNode = { id: node.id, name: node.name, type: node.type, }; if (node.type === 'INSTANCE') { if (node.componentId) { simplified.componentId = node.componentId; } if (node.componentProperties) { simplified.componentProperties = Object.entries(node.componentProperties).map(([name, prop]) => ({ name, value: String(prop.value), type: prop.type, })); } } if (node.characters) { simplified.text = node.characters; } if (node.style && Object.keys(node.style).length > 0) { simplified.textStyle = findOrCreateVar(globalVars, simplifyTextStyle(node.style), 'style'); } if (node.fills?.length) { const fills = node.fills.filter(isVisible).map(simplifyFill); if (fills.length) { simplified.fills = findOrCreateVar(globalVars, fills, 'fill'); } } const strokes = simplifyStrokes(node); if (strokes.colors?.length) { simplified.strokes = findOrCreateVar(globalVars, strokes, 'stroke'); } const effects = simplifyEffects(node); if (Object.keys(effects).length) { simplified.effects = findOrCreateVar(globalVars, effects, 'effect'); } if (typeof node.opacity === 'number' && node.opacity !== 1) { simplified.opacity = node.opacity; } if (typeof node.cornerRadius === 'number') { simplified.borderRadius = `${node.cornerRadius}px`; } else if (node.rectangleCornerRadii?.length === 4) { const [tl, tr, br, bl] = node.rectangleCornerRadii; if (tl === tr && tr === br && br === bl) { simplified.borderRadius = `${tl}px`; } else { simplified.borderRadius = `${tl}px ${tr}px ${br}px ${bl}px`; } } const layout = simplifyLayout(node, parent); if (Object.keys(layout).length > 1 || layout.mode !== 'none') { simplified.layout = findOrCreateVar(globalVars, layout, 'layout'); } if (node.children?.length) { const children = node.children .filter(isVisible) .map((child) => simplifyNode(child, globalVars, node)) .filter((child): child is SimplifiedNode => child !== null); if (children.length) { simplified.children = children; } } if (node.type === 'VECTOR') { simplified.type = 'IMAGE-SVG'; } return simplified; } export function simplifyDesign(data: FigmaFileResponse | FigmaNodeResponse): SimplifiedDesign { const isNodeResponse = 'nodes' in data; let nodesToProcess: FigmaNode[]; let components: Record = {}; let componentSets: Record = {}; if (isNodeResponse) { const nodeResponses = Object.values(data.nodes); nodesToProcess = nodeResponses.map((n) => n.document); for (const nodeResponse of nodeResponses) { if (nodeResponse.components) { Object.assign(components, nodeResponse.components); } if (nodeResponse.componentSets) { Object.assign(componentSets, nodeResponse.componentSets); } } } else { nodesToProcess = data.document?.children || []; components = data.components || {}; componentSets = data.componentSets || {}; } const globalVars: GlobalVars = { styles: {} }; const simplifiedNodes = nodesToProcess .filter(isVisible) .map((node) => simplifyNode(node, globalVars)) .filter((node): node is SimplifiedNode => node !== null); return removeEmptyKeys({ name: data.name, lastModified: data.lastModified, thumbnailUrl: data.thumbnailUrl || '', nodes: simplifiedNodes, components, componentSets, globalVars, }); } export function removeEmptyKeys(obj: T): T { if (typeof obj !== 'object' || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(removeEmptyKeys).filter((v) => v !== undefined) as unknown as T; } const result = {} as Record; for (const [key, value] of Object.entries(obj as Record)) { const cleaned = removeEmptyKeys(value); const isEmpty = cleaned === undefined || (Array.isArray(cleaned) && cleaned.length === 0) || (typeof cleaned === 'object' && cleaned !== null && Object.keys(cleaned).length === 0); if (!isEmpty) { result[key] = cleaned; } } return result as T; }