KyklosIO's picture
feat: add Intelligent Workflow Elements tab, Dockerfile, presets, and patches installer
cc14d64
Raw History Blame Contribute Delete
13 kB
<?php
/**
* Reads a Figma document tree — REST `GET /v1/files/:key` output, or the same
* shape emitted by the Style Bridge plugin — and yields both halves of an import:
* the design tokens, and the frames that become YOOtheme layouts.
*
* Tokens are inferred from usage here, because a document tree carries fills and
* text runs rather than named variables. When a file does have variables, the
* plugin sends them separately and those win.
*/
namespace Fyoo\FigmaBridge;
class Figma
{
private array $document;
private array $textNodes = [];
private array $fillCounts = [];
private array $radii = [];
private array $shadows = [];
private ?array $pageFrame = null;
public function __construct(array $document)
{
// Accept a whole REST response, a plugin payload, or a bare node.
$this->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;
}
}