Spaces:
Sleeping
Sleeping
Download bridge/src/Layout.php from acecalisto3/UIKitV3-Automator: direct link, hf CLI and curl.
- Browser
- Download file 13.4 kB
-
https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Layout.php
- Command line
-
hf download hf://spaces/acecalisto3/UIKitV3-Automator/bridge/src/Layout.php
-
curl -L -o Layout.php https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Layout.php
13.4 kB
| /** | |
| * Figma frame -> 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' => '<p>' . nl2br(htmlspecialchars($content, ENT_QUOTES)) . '</p>'], | |
| ]; | |
| } | |
| 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 .= "<div class=\"$class\">\n <div class=\"uk-container\">\n"; | |
| foreach ($section['children'] as $row) { | |
| $columns = $row['children']; | |
| $out .= ' <div class="uk-grid-match uk-child-width-expand@m" uk-grid>' . "\n"; | |
| foreach ($columns as $column) { | |
| $out .= " <div>\n"; | |
| foreach ($column['children'] as $element) { | |
| $out .= ' ' . $this->elementHtml($element) . "\n"; | |
| } | |
| $out .= " </div>\n"; | |
| } | |
| $out .= " </div>\n"; | |
| } | |
| $out .= " </div>\n</div>\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</%1$s>', | |
| $props['title_element'] ?? 'h2', | |
| ($props['text_align'] ?? '') === 'center' ? ' uk-text-center' : '', | |
| htmlspecialchars($props['content'] ?? '', ENT_QUOTES) | |
| ), | |
| 'text' => $props['content'] ?? '', | |
| 'button' => sprintf( | |
| '<a class="uk-button uk-button-%s" href="%s">%s</a>', | |
| $props['style'] ?? 'primary', | |
| htmlspecialchars($props['link'] ?? '#', ENT_QUOTES), | |
| htmlspecialchars($props['content'] ?? '', ENT_QUOTES) | |
| ), | |
| 'image' => sprintf( | |
| '<img src="%s" alt="%s" width="%d" loading="lazy">', | |
| htmlspecialchars($props['image'] ?? '', ENT_QUOTES), | |
| htmlspecialchars($props['alt'] ?? '', ENT_QUOTES), | |
| (int) ($props['image_width'] ?? 0) | |
| ), | |
| default => '', | |
| }; | |
| } | |
| } | |