UIKitV3-Automator / bridge /src /Layout.php
KyklosIO's picture
feat: add Intelligent Workflow Elements tab, Dockerfile, presets, and patches installer
cc14d64
Raw History Blame Contribute Delete
13.4 kB
<?php
/**
* 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 => '',
};
}
}