document = $document['document'] ?? $document; if (isset($this->document['document'])) { $this->document = $this->document['document']; } if (!isset($this->document['children']) && !isset($this->document['type'])) { throw new \InvalidArgumentException('Not a Figma document: no node tree found.'); } $this->document['name'] = $document['name'] ?? $this->document['name'] ?? 'Figma Import'; $this->walk($this->document, 0); } public static function fromJson(string $json): self { $data = json_decode($json, true); if (!is_array($data)) { throw new \InvalidArgumentException('Figma payload is not valid JSON.'); } return new self($data); } /** Fetches a file over the REST API with a personal access token. */ public static function fetch(string $fileKeyOrUrl, string $token): self { if (preg_match('~figma\.com/(?:file|design|proto)/([A-Za-z0-9]+)~', $fileKeyOrUrl, $m)) { $fileKeyOrUrl = $m[1]; } if (!preg_match('~^[A-Za-z0-9]{10,}$~', $fileKeyOrUrl)) { throw new \InvalidArgumentException('Not a Figma file key or URL.'); } $context = stream_context_create(['http' => [ 'method' => 'GET', 'header' => "X-Figma-Token: $token\r\n", 'timeout' => 90, 'ignore_errors' => true, ]]); $body = @file_get_contents("https://api.figma.com/v1/files/$fileKeyOrUrl", false, $context); if ($body === false) { throw new \RuntimeException('Could not reach the Figma API.'); } $data = json_decode($body, true); if (!is_array($data)) { throw new \RuntimeException('Figma API returned an unreadable response.'); } if (isset($data['err']) && $data['err']) { throw new \RuntimeException('Figma API: ' . $data['err']); } return new self($data); } public function name(): string { return (string) ($this->document['name'] ?? 'Figma Import'); } private function walk(array $node, int $depth): void { $type = $node['type'] ?? ''; if ($type === 'TEXT' && isset($node['style']['fontSize'])) { $this->textNodes[] = $node; } // Weighted by area: a colour covering a hero counts for more than one chip. foreach ($this->solidFills($node) as $color) { $this->fillCounts[$color] = ($this->fillCounts[$color] ?? 0) + $this->area($node); } if (!empty($node['cornerRadius']) && is_numeric($node['cornerRadius'])) { $radius = (int) $node['cornerRadius']; $this->radii[$radius] = ($this->radii[$radius] ?? 0) + 1; } foreach ($node['effects'] ?? [] as $effect) { if (($effect['type'] ?? '') === 'DROP_SHADOW' && ($effect['visible'] ?? true)) { $shadow = self::shadow($effect); $this->shadows[$shadow] = ($this->shadows[$shadow] ?? 0) + 1; } } if ($depth <= 2 && $type === 'FRAME' && ($node['absoluteBoundingBox']['width'] ?? 0) >= 600) { if ($this->pageFrame === null || $this->area($node) > $this->area($this->pageFrame)) { $this->pageFrame = $node; } } foreach ($node['children'] ?? [] as $child) { $this->walk($child, $depth + 1); } } private function area(array $node): int { $box = $node['absoluteBoundingBox'] ?? null; return $box ? max(1, (int) (($box['width'] ?? 1) * ($box['height'] ?? 1) / 1000)) : 1; } /** @return string[] hex colours of a node's visible solid fills */ private function solidFills(array $node): array { $colors = []; foreach ($node['fills'] ?? [] as $fill) { if (($fill['type'] ?? '') === 'SOLID' && ($fill['visible'] ?? true)) { $colors[] = self::hex($fill['color'], $fill['opacity'] ?? 1); } } if (!$colors && isset($node['backgroundColor'])) { $colors[] = self::hex($node['backgroundColor']); } return $colors; } public static function hex(array $color, float $opacity = 1.0): string { $channel = fn(float $value): string => str_pad(dechex((int) round(max(0, min(1, $value)) * 255)), 2, '0', STR_PAD_LEFT); $hex = '#' . $channel($color['r'] ?? 0) . $channel($color['g'] ?? 0) . $channel($color['b'] ?? 0); $alpha = ($color['a'] ?? 1) * $opacity; return strtoupper($alpha >= 0.999 ? $hex : $hex . $channel($alpha)); } private static function shadow(array $effect): string { return sprintf( '%dpx %dpx %dpx %dpx %s', (int) ($effect['offset']['x'] ?? 0), (int) ($effect['offset']['y'] ?? 0), (int) ($effect['radius'] ?? 0), (int) ($effect['spread'] ?? 0), self::hex($effect['color'] ?? []) ); } public static function luminance(string $hex): float { $hex = substr(ltrim($hex, '#'), 0, 6); if (strlen($hex) < 6) { return 0.5; } [$r, $g, $b] = array_map('hexdec', str_split($hex, 2)); return (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255; } public function tokens(): Tokens { arsort($this->fillCounts); $surface = $this->pageFrame ? ($this->solidFills($this->pageFrame)[0] ?? null) : null; $color = []; if ($surface !== null) { $color['surface'] = $surface; } $accents = []; $lights = []; foreach (array_keys($this->fillCounts) as $hex) { if ($hex === $surface || strlen($hex) > 7) { continue; } $luminance = self::luminance($hex); if ($luminance > 0.06 && $luminance < 0.80) { $accents[] = $hex; } elseif ($luminance >= 0.80) { $lights[] = $hex; } } if (isset($accents[0])) { $color['brand-primary'] = $accents[0]; $color['on-primary'] = self::luminance($accents[0]) > 0.55 ? '#000000' : '#FFFFFF'; } if (isset($accents[1])) { $color['brand-secondary'] = $accents[1]; } $muted = array_values(array_filter($lights, fn($c) => !in_array($c, ['#FFF', '#FFFFFF'], true))); if (isset($muted[0])) { $color['surface-muted'] = $muted[0]; } $textColors = []; foreach ($this->textNodes as $node) { foreach ($this->solidFills($node) as $hex) { $bucket = (float) $node['style']['fontSize'] <= 20 ? 'text' : 'heading'; $textColors[$bucket][$hex] = ($textColors[$bucket][$hex] ?? 0) + 1; } } foreach (['text', 'heading'] as $bucket) { if (!empty($textColors[$bucket])) { arsort($textColors[$bucket]); $color[$bucket] = (string) array_key_first($textColors[$bucket]); } } $typography = $this->typography(); arsort($this->radii); $radius = $this->radii ? ['card' => array_key_first($this->radii) . 'px'] : []; $shadowList = array_keys($this->shadows); usort($shadowList, fn($a, $b) => strlen($a) <=> strlen($b)); $shadow = []; foreach (['shadow-md', 'shadow-lg', 'shadow-xl'] as $index => $key) { if (isset($shadowList[$index])) { $shadow[$key] = $shadowList[$index]; } } $spacing = []; if ($this->pageFrame) { if ($width = (int) ($this->pageFrame['absoluteBoundingBox']['width'] ?? 0)) { $spacing['container'] = min($width, 1600) . 'px'; } if ($padding = (int) ($this->pageFrame['paddingTop'] ?? 0)) { $spacing['section'] = $padding . 'px'; } } return new Tokens(Tokens::validate([ 'name' => $this->name(), 'source' => 'figma', 'version' => '0.1', 'generatedAt' => gmdate('c'), 'tokens' => [ 'color' => $color, 'typography' => $typography, 'spacing' => $spacing, 'radius' => $radius, 'shadow' => $shadow, ], 'components' => $this->components(), 'assets' => [], ])); } /** The most-used text run is the body copy; every larger size is a heading step. */ private function typography(): array { $styles = []; foreach ($this->textNodes as $node) { $style = $node['style']; $size = (int) round((float) $style['fontSize']); $key = $size . '/' . ($style['fontWeight'] ?? 400); if (!isset($styles[$key])) { $lineHeight = isset($style['lineHeightPercentFontSize']) ? round($style['lineHeightPercentFontSize'] / 100, 3) : (isset($style['lineHeightPx']) ? round($style['lineHeightPx'] / max(1, $size), 3) : 1.5); $styles[$key] = [ 'size' => $size, 'count' => 0, 'style' => array_filter([ 'fontFamily' => $style['fontFamily'] ?? null, 'fontSize' => $size . 'px', 'fontWeight' => isset($style['fontWeight']) ? (string) (int) $style['fontWeight'] : null, 'lineHeight' => (string) $lineHeight, 'letterSpacing' => isset($style['letterSpacing']) && abs((float) $style['letterSpacing']) > 0.01 ? round((float) $style['letterSpacing'] / max(1, $size), 3) . 'em' : null, ]), ]; } $styles[$key]['count']++; } if (!$styles) { return []; } uasort($styles, fn($a, $b) => $b['count'] <=> $a['count']); $body = reset($styles); $typography = ['body' => $body['style']]; $headings = array_values(array_filter($styles, fn($s) => $s['size'] > $body['size'])); usort($headings, fn($a, $b) => $b['size'] <=> $a['size']); $names = ['heading-xl', 'heading-lg', 'heading-md', 'heading-sm', 'heading-xs', 'heading-2xs']; foreach (array_slice($headings, 0, 6) as $index => $heading) { $typography[$names[$index]] = $heading['style']; } return $typography; } /** Component and instance names, so the mapper can bind them to YOOtheme elements. */ private function components(): array { $names = []; $collect = function (array $node) use (&$collect, &$names): void { if (in_array($node['type'] ?? '', ['COMPONENT', 'COMPONENT_SET', 'INSTANCE'], true)) { $names[$node['name']] = ['role' => '', 'target' => '', 'overrides' => []]; } foreach ($node['children'] ?? [] as $child) { $collect($child); } }; $collect($this->document); return array_slice($names, 0, 100, true); } /** Top-level frames wide enough to be pages, in canvas order. */ public function frames(): array { $frames = []; foreach ($this->document['children'] ?? [] as $canvas) { $nodes = ($canvas['type'] ?? '') === 'CANVAS' ? ($canvas['children'] ?? []) : [$canvas]; foreach ($nodes as $node) { if (($node['type'] ?? '') === 'FRAME' && ($node['absoluteBoundingBox']['width'] ?? 0) >= 600) { $frames[] = $node; } } } if (!$frames && ($this->document['type'] ?? '') === 'FRAME') { $frames[] = $this->document; } return $frames; } }