YOOtheme page-builder layout. * * The layout carries no colours, sizes or fonts of its own. It refers to the * style — a section whose Figma fill is the brand colour becomes * `"style": "primary"`, not `"background": "#647b91"` — so importing it into a * site running the child theme from the same import lands pre-styled, and * re-styling later moves the layout with it. * * ponytail: builder element vocabulary is deliberately small (section, row, * column, headline, text, image, button, divider). Anything unrecognised becomes * its text content rather than a guessed element; the HTML twin is the escape * hatch when a frame does not fit the builder's grid model. */ namespace Fyoo\FigmaBridge; class Layout { /** Builder export version. Bump to match the target YOOtheme Pro if it refuses an import. */ public const BUILDER_VERSION = '4.5.6'; private array $frame; private array $styleColors; private string $name; /** * @param array $styleColors role => hex, from the compiled token set, so fills * can be resolved to section styles instead of literals. */ public function __construct(array $frame, array $styleColors = []) { $this->frame = $frame; $this->styleColors = array_map('strtoupper', array_filter($styleColors)); $this->name = (string) ($frame['name'] ?? 'Layout'); } public function name(): string { return $this->name; } public function slug(): string { return trim(strtolower(preg_replace('~[^A-Za-z0-9]+~', '-', $this->name)), '-') ?: 'layout'; } /** The importable builder document. */ public function builder(): array { return [ 'type' => 'layout', 'version' => self::BUILDER_VERSION, 'children' => array_values(array_filter(array_map( fn(array $node) => $this->section($node), $this->sections() ))), ]; } public function json(): string { return json_encode($this->builder(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } /** * Direct children that span most of the frame are sections; a frame with none * (a flat design with no grouping) is treated as one section itself. */ private function sections(): array { $width = (float) ($this->frame['absoluteBoundingBox']['width'] ?? 0); $sections = []; foreach ($this->frame['children'] ?? [] as $child) { $childWidth = (float) ($child['absoluteBoundingBox']['width'] ?? 0); if ($width <= 0 || $childWidth >= $width * 0.6) { $sections[] = $child; } } return $sections ?: [$this->frame]; } private function section(array $node): ?array { $children = $this->rows($node); if (!$children) { return null; } $props = ['style' => $this->sectionStyle($node)]; $padding = (float) ($node['paddingTop'] ?? 0); if ($padding > 0) { $props['padding'] = $padding >= 80 ? 'large' : ($padding <= 20 ? 'small' : 'default'); } return ['type' => 'section', 'props' => $props, 'children' => $children]; } /** Resolves a fill to a named YOOtheme section style rather than a literal colour. */ private function sectionStyle(array $node): string { $fill = $this->fill($node); if ($fill === null) { return 'default'; } foreach (['brand-primary' => 'primary', 'brand-secondary' => 'secondary', 'surface-muted' => 'muted'] as $token => $style) { if (isset($this->styleColors[$token]) && $this->styleColors[$token] === $fill) { return $style; } } // An unmatched dark fill is still visually a "primary" band; light stays default. if (isset($this->styleColors['surface']) && $this->styleColors['surface'] === $fill) { return 'default'; } return Figma::luminance($fill) < 0.5 ? 'secondary' : 'muted'; } private function fill(array $node): ?string { foreach ($node['fills'] ?? [] as $paint) { if (($paint['type'] ?? '') === 'SOLID' && ($paint['visible'] ?? true)) { return Figma::hex($paint['color'], $paint['opacity'] ?? 1); } } return null; } /** * A horizontal auto-layout (or a run of side-by-side children) becomes one row * of columns; everything else stacks into a single full-width column. */ private function rows(array $node): array { $content = $this->contentChildren($node); if (!$content) { return []; } if ($this->isHorizontal($node, $content)) { $columns = []; foreach ($content as $child) { $elements = $this->elements($child); if ($elements) { $columns[] = ['type' => 'column', 'props' => $this->columnProps($child), 'children' => $elements]; } } return $columns ? [['type' => 'row', 'children' => $columns]] : []; } $elements = []; foreach ($content as $child) { $elements = array_merge($elements, $this->elements($child)); } return $elements ? [['type' => 'row', 'children' => [['type' => 'column', 'children' => $elements]]]] : []; } /** Unwraps a single wrapper frame so a container div does not become a column. */ private function contentChildren(array $node): array { $children = $node['children'] ?? []; while (count($children) === 1 && in_array($children[0]['type'] ?? '', ['FRAME', 'GROUP'], true) && !empty($children[0]['children'])) { $children = $children[0]['children']; } return $children; } private function isHorizontal(array $node, array $children): bool { if (($node['layoutMode'] ?? '') === 'HORIZONTAL') { return count($children) > 1; } if (count($children) < 2) { return false; } // No auto-layout: infer from geometry — siblings sharing a band of rows. $tops = array_map(fn($c) => (float) ($c['absoluteBoundingBox']['y'] ?? 0), $children); $heights = array_map(fn($c) => (float) ($c['absoluteBoundingBox']['height'] ?? 1), $children); return (max($tops) - min($tops)) < (min($heights) ?: 1) * 0.5; } private function columnProps(array $node): array { $parentWidth = (float) ($this->frame['absoluteBoundingBox']['width'] ?? 0); $width = (float) ($node['absoluteBoundingBox']['width'] ?? 0); if ($parentWidth <= 0 || $width <= 0) { return []; } $ratio = $width / $parentWidth; foreach (['1-4' => 0.25, '1-3' => 0.333, '1-2' => 0.5, '2-3' => 0.667, '3-4' => 0.75] as $fraction => $value) { if (abs($ratio - $value) < 0.08) { return ['width' => $fraction]; } } return []; } /** @return array builder elements for one node subtree */ private function elements(array $node, int $depth = 0): array { $type = $node['type'] ?? ''; if ($type === 'TEXT') { return [$this->textElement($node)]; } if ($this->isButton($node)) { return [[ 'type' => 'button', 'props' => ['content' => $this->firstText($node) ?: 'Button', 'link' => '#', 'style' => 'primary'], ]]; } if ($this->isImage($node)) { return [[ 'type' => 'image', 'props' => [ 'image' => 'templates/yootheme_child/assets/' . $this->slugName($node) . '.png', 'alt' => $node['name'] ?? '', 'image_width' => (int) ($node['absoluteBoundingBox']['width'] ?? 0), ], ]]; } if ($depth > 6) { return []; } $elements = []; foreach ($this->contentChildren($node) as $child) { $elements = array_merge($elements, $this->elements($child, $depth + 1)); } return $elements; } private function textElement(array $node): array { $content = trim((string) ($node['characters'] ?? '')); $size = (float) ($node['style']['fontSize'] ?? 16); $weight = (int) ($node['style']['fontWeight'] ?? 400); // Big or bold and short reads as a headline; a paragraph is a paragraph. if (($size >= 24 || ($weight >= 600 && mb_strlen($content) < 80)) && !str_contains($content, "\n")) { return [ 'type' => 'headline', 'props' => [ 'content' => $content, 'title_element' => $size >= 40 ? 'h1' : ($size >= 30 ? 'h2' : ($size >= 24 ? 'h3' : 'h4')), 'text_align' => strtolower((string) ($node['style']['textAlignHorizontal'] ?? 'left')) === 'center' ? 'center' : 'left', ], ]; } return [ 'type' => 'text', 'props' => ['content' => '

' . nl2br(htmlspecialchars($content, ENT_QUOTES)) . '

'], ]; } private function isButton(array $node): bool { if (preg_match('~\b(button|btn|cta)\b~i', (string) ($node['name'] ?? ''))) { return true; } $box = $node['absoluteBoundingBox'] ?? null; return $box && !empty($node['cornerRadius']) && $this->fill($node) !== null && ($box['height'] ?? 0) <= 72 && ($box['width'] ?? 0) <= 320 && $this->firstText($node) !== ''; } private function isImage(array $node): bool { foreach ($node['fills'] ?? [] as $paint) { if (($paint['type'] ?? '') === 'IMAGE') { return true; } } return in_array($node['type'] ?? '', ['VECTOR', 'BOOLEAN_OPERATION'], true) || (bool) preg_match('~\b(image|img|photo|logo|icon)\b~i', (string) ($node['name'] ?? '')); } private function firstText(array $node): string { if (($node['type'] ?? '') === 'TEXT') { return trim((string) ($node['characters'] ?? '')); } foreach ($node['children'] ?? [] as $child) { $text = $this->firstText($child); if ($text !== '') { return $text; } } return ''; } private function slugName(array $node): string { return trim(strtolower(preg_replace('~[^A-Za-z0-9]+~', '-', (string) ($node['name'] ?? 'image'))), '-') ?: 'image'; } /** * The same tree as UIkit markup. The builder JSON is the better artefact when * it imports cleanly; this always works, in a Custom HTML element or a page * template, and uses only classes the child theme already styles. */ public function html(): string { $out = ''; foreach ($this->builder()['children'] as $section) { $style = $section['props']['style'] ?? 'default'; $class = 'uk-section' . ($style !== 'default' ? " uk-section-$style" : ''); $out .= "
\n
\n"; foreach ($section['children'] as $row) { $columns = $row['children']; $out .= '
' . "\n"; foreach ($columns as $column) { $out .= "
\n"; foreach ($column['children'] as $element) { $out .= ' ' . $this->elementHtml($element) . "\n"; } $out .= "
\n"; } $out .= "
\n"; } $out .= "
\n
\n"; } return $out; } private function elementHtml(array $element): string { $props = $element['props'] ?? []; return match ($element['type']) { 'headline' => sprintf( '<%1$s class="uk-%1$s%2$s">%3$s', $props['title_element'] ?? 'h2', ($props['text_align'] ?? '') === 'center' ? ' uk-text-center' : '', htmlspecialchars($props['content'] ?? '', ENT_QUOTES) ), 'text' => $props['content'] ?? '', 'button' => sprintf( '%s', $props['style'] ?? 'primary', htmlspecialchars($props['link'] ?? '#', ENT_QUOTES), htmlspecialchars($props['content'] ?? '', ENT_QUOTES) ), 'image' => sprintf( '%s', htmlspecialchars($props['image'] ?? '', ENT_QUOTES), htmlspecialchars($props['alt'] ?? '', ENT_QUOTES), (int) ($props['image_width'] ?? 0) ), default => '', }; } }