tokens = $tokens; $this->mapper = $mapper ?: new Mapper(); $this->resolved = $this->mapper->resolve($tokens); } /** name => source path, for imports whose assets are real files rather than inline data. */ public function withAssetFiles(array $files): self { $this->assetFiles = $files; return $this; } /** * Ships a stylesheet as the child theme's css/theme.css. Variables carry the * design system; this carries the layout and component CSS they cannot express. */ public function withStylesheet(string $css): self { $this->stylesheet = trim($css); return $this; } public function resolution(): array { return $this->resolved; } public function slug(): string { $slug = strtolower(preg_replace('~[^A-Za-z0-9]+~', '_', $this->tokens->data['name'])); return trim($slug, '_') ?: 'figma_import'; } public function less(): string { $name = $this->tokens->data['name']; $out = [ '//', '// ' . $name . ' — generated by Figma → YOOtheme Style Bridge', '// Source: ' . $this->tokens->data['source'] . ' · ' . $this->tokens->data['generatedAt'], '// Edit the tokens in Figma and re-import; local edits below are overwritten.', '//', '', '// ---------- Design tokens ----------', ]; foreach (Tokens::GROUPS as $group) { $lines = []; foreach ($this->tokens->group($group) as $key => $value) { if ($group === 'typography') { foreach ($value as $prop => $propValue) { $lines[] = sprintf('@fyoo-%s-%s-%s: %s;', $group, $key, $this->dash($prop), $this->quote($prop, $propValue)); } } else { $lines[] = sprintf('@fyoo-%s-%s: %s;', $group, $key, $value); } } if ($lines) { $out[] = ''; $out[] = '// ' . $group; $out = array_merge($out, $lines); } } $out[] = ''; $out[] = '// ---------- YOOtheme global variables ----------'; $rules = []; foreach ($this->resolved['mapped'] as $item) { $out = array_merge($out, $this->emit($item, $rules)); } if ($rules) { $out[] = ''; $out[] = '// ---------- Component rules (no matching Less variable upstream) ----------'; $out = array_merge($out, $rules); } $out[] = ''; return implode("\n", $out) . "\n"; } /** @param array $rules collected by reference: CSS for design intent with no variable to carry it */ private function emit(array $item, array &$rules): array { $ref = sprintf('@fyoo-%s-%s', $item['group'], $item['key']); if ($item['group'] !== 'typography') { $lines = ["// {$item['role']} ← {$item['key']}"]; foreach ($item['variables'] as $variable) { $lines[] = sprintf('%s: %s;', $variable, $ref); } if ($item['role'] === 'radius.default') { $rules[] = sprintf('.uk-card, .uk-button, .uk-input, .uk-textarea, .uk-select { border-radius: %s; }', $ref); } return $lines; } $style = $item['value']; $lines = ["// {$item['role']} ← {$item['key']}"]; $has = fn(string $p) => isset($style[$p]); if ($item['role'] === 'type.body') { if ($has('fontFamily')) { $lines[] = sprintf('@global-font-family: %s-font-family;', $ref); $lines[] = sprintf('@base-body-font-family: %s-font-family;', $ref); } if ($has('fontSize')) { $lines[] = sprintf('@global-font-size: %s-font-size;', $ref); } if ($has('lineHeight')) { $lines[] = sprintf('@global-line-height: %s-line-height;', $ref); } return $lines; } foreach ($item['variables'] as $variable) { if ($has('fontSize')) { $lines[] = sprintf('%s: %s-font-size;', $variable, $ref); } } // h1..h6: UIkit only exposes a size variable per level, the rest is a rule. if (preg_match('~^type\.h([1-6])$~', $item['role'], $m)) { $decls = []; foreach (['fontWeight' => 'font-weight', 'lineHeight' => 'line-height', 'letterSpacing' => 'letter-spacing', 'textTransform' => 'text-transform'] as $prop => $css) { if ($has($prop)) { $decls[] = sprintf('%s: %s-%s;', $css, $ref, $this->dash($prop)); } } if ($decls) { $rules[] = sprintf('h%1$s, .uk-h%1$s { %2$s }', $m[1], implode(' ', $decls)); } } return $lines; } public function manifest(): array { return [ 'name' => $this->tokens->data['name'], 'slug' => $this->slug(), 'source' => $this->tokens->data['source'], 'fileKey' => $this->tokens->data['fileKey'], 'generatedAt' => $this->tokens->data['generatedAt'], 'compiledAt' => gmdate('c'), 'bridge' => '0.1.0', 'target' => 'yootheme-pro-child-theme', 'tokenCount' => $this->tokens->count(), 'mapped' => count($this->resolved['mapped']), 'unmapped' => count($this->resolved['unmapped']), ]; } public function mappings(): array { $components = []; foreach ($this->tokens->data['components'] as $name => $def) { $auto = $this->mapper->component($name); $components[$name] = [ 'role' => $def['role'] ?: $auto['role'], 'target' => $def['target'] ?: $auto['target'], 'overrides' => $def['overrides'], ]; } return ['tokens' => $this->resolved, 'components' => $components]; } public function templateDetails(): string { $slug = $this->slug(); $name = htmlspecialchars($this->tokens->data['name'], ENT_XML1); return << yootheme_child_{$slug} 1.0.0 {$this->today()} Figma → YOOtheme Style Bridge {$name} design system, imported from Figma. 0 yootheme less css assets XML; } /** Resolved value for a semantic role, or null when nothing claimed it. */ public function valueForRole(string $role, ?string $property = null) { foreach ($this->resolved['mapped'] as $item) { if ($item['role'] === $role) { return $property === null ? $item['value'] : ($item['value'][$property] ?? null); } } return null; } /** * CSS that approximates the compiled Less against the UIkit CDN, so the design * can be judged on real components before anything is written to the site. */ public function previewCss(string $scope = '.uk-preview'): string { $v = fn(string $role, ?string $prop = null) => $this->valueForRole($role, $prop); $decl = function (array $pairs): string { $out = ''; foreach ($pairs as $prop => $value) { if ($value !== null && $value !== '') { $out .= sprintf('%s:%s;', $prop, $value); } } return $out; }; $radius = $v('radius.default'); $primary = $v('color.primary'); $rules = []; $rules[''] = $decl([ 'background' => $v('color.background'), 'color' => $v('color.text'), 'font-family' => $v('type.body', 'fontFamily'), 'font-size' => $v('type.body', 'fontSize'), 'line-height' => $v('type.body', 'lineHeight'), ]); $rules['h1,h2,h3,h4,h5,h6,.uk-h1,.uk-h2,.uk-h3'] = $decl(['color' => $v('color.emphasis')]); foreach (range(1, 6) as $level) { $style = $v("type.h$level"); if (is_array($style)) { $rules["h$level, .uk-h$level"] = $decl([ 'font-family' => $style['fontFamily'] ?? null, 'font-size' => $style['fontSize'] ?? null, 'font-weight' => $style['fontWeight'] ?? null, 'line-height' => $style['lineHeight'] ?? null, 'letter-spacing' => $style['letterSpacing'] ?? null, ]); } } $rules['.uk-button'] = $decl(['border-radius' => $radius]); $rules['.uk-button-primary'] = $decl(['background' => $primary, 'color' => $v('color.inverse')]); $rules['.uk-button-secondary'] = $decl(['background' => $v('color.secondary'), 'color' => $v('color.inverse')]); $rules['.uk-button-default'] = $decl(['border-color' => $v('color.border'), 'color' => $v('color.text')]); $rules['.uk-card-default'] = $decl([ 'background' => $v('color.background'), 'color' => $v('color.text'), 'border-radius' => $radius, 'box-shadow' => $v('shadow.medium'), ]); $rules['.uk-card-primary'] = $decl(['background' => $primary, 'color' => $v('color.inverse'), 'border-radius' => $radius]); $rules['.uk-input, .uk-textarea, .uk-select'] = $decl(['border-radius' => $radius, 'border-color' => $v('color.border')]); $rules['.uk-text-muted'] = $decl(['color' => $v('color.text-muted')]); $rules['.uk-link, a'] = $decl(['color' => $v('color.link') ?: $primary]); $rules['.uk-label'] = $decl(['background' => $primary, 'color' => $v('color.inverse')]); $rules['.uk-alert-danger'] = $decl(['background' => $v('color.danger'), 'color' => $v('color.inverse'), 'border-radius' => $radius]); $rules['.uk-alert-success'] = $decl(['background' => $v('color.success'), 'color' => $v('color.inverse'), 'border-radius' => $radius]); $rules['.uk-section'] = $decl(['padding-top' => $v('space.section'), 'padding-bottom' => $v('space.section')]); $rules['.uk-container'] = $decl(['max-width' => $v('space.container')]); // Section styles are how a layout refers to the palette without naming colours. $rules['.uk-section-muted, .uk-tile-muted'] = $decl(['background' => $v('color.muted')]); $rules['.uk-section-primary, .uk-tile-primary, .uk-background-primary, .uk-overlay-primary'] = $decl([ 'background' => $primary, 'color' => $v('color.inverse'), ]); $rules['.uk-section-secondary, .uk-tile-secondary, .uk-background-secondary'] = $decl([ 'background' => $v('color.secondary'), 'color' => $v('color.inverse'), ]); // Navigation $rules['.uk-navbar-container:not(.uk-navbar-transparent)'] = $decl([ 'background' => $v('color.background'), 'border-radius' => $radius, 'box-shadow' => $v('shadow.medium'), ]); $rules['.uk-navbar-nav>li>a, .uk-navbar-item, .uk-navbar-toggle'] = $decl([ 'color' => $v('color.text'), 'font-family' => $v('type.body', 'fontFamily'), ]); $rules['.uk-navbar-nav>li.uk-active>a, .uk-navbar-nav>li:hover>a'] = $decl(['color' => $primary]); $rules['.uk-dropdown, .uk-navbar-dropdown, .uk-dropbar, .uk-modal-dialog'] = $decl([ 'background' => $v('color.background'), 'color' => $v('color.text'), 'border-radius' => $radius, 'box-shadow' => $v('shadow.large'), ]); $rules['.uk-dropdown-nav>li.uk-active>a, .uk-nav-default>li.uk-active>a, .uk-nav-primary>li.uk-active>a'] = $decl(['color' => $primary]); $rules['.uk-nav-header'] = $decl(['color' => $v('color.emphasis')]); $rules['.uk-nav-divider'] = $decl(['border-top-color' => $v('color.border')]); $rules['.uk-subnav-pill>.uk-active>a'] = $decl([ 'background' => $primary, 'color' => $v('color.inverse'), 'border-radius' => $radius, ]); $rules['.uk-tab>.uk-active>a'] = $decl(['border-color' => $primary, 'color' => $v('color.emphasis')]); $rules['.uk-breadcrumb>.uk-active>*, .uk-pagination>.uk-active>*'] = $decl(['color' => $primary]); $rules['.uk-offcanvas-bar'] = $decl(['background' => $v('color.secondary')]); // Indicators $rules['.uk-badge, .uk-marker'] = $decl([ 'background' => $primary, 'color' => $v('color.inverse'), ]); $rules['.uk-label-success'] = $decl(['background' => $v('color.success')]); $rules['.uk-label-warning'] = $decl(['background' => $v('color.warning')]); $rules['.uk-label-danger'] = $decl(['background' => $v('color.danger')]); $rules['.uk-progress'] = $decl(['background' => $v('color.muted'), 'border-radius' => $radius]); $rules['.uk-progress::-webkit-progress-value'] = $decl(['background' => $primary, 'border-radius' => $radius]); $rules['.uk-progress::-moz-progress-bar'] = $decl(['background' => $primary, 'border-radius' => $radius]); $rules['.uk-text-primary'] = $decl(['color' => $primary]); $rules['.uk-text-emphasis'] = $decl(['color' => $v('color.emphasis')]); $rules['.uk-text-danger'] = $decl(['color' => $v('color.danger')]); $rules['.uk-text-success'] = $decl(['color' => $v('color.success')]); $rules['.uk-alert-primary'] = $decl(['background' => $v('color.muted'), 'color' => $primary, 'border-radius' => $radius]); $rules['.uk-alert-warning'] = $decl(['background' => $v('color.warning'), 'color' => $v('color.inverse'), 'border-radius' => $radius]); // Structure $rules['.uk-card-secondary'] = $decl([ 'background' => $v('color.secondary'), 'color' => $v('color.inverse'), 'border-radius' => $radius, ]); $rules['.uk-card-hover:hover'] = $decl(['box-shadow' => $v('shadow.large')]); $rules['.uk-table th'] = $decl(['color' => $v('color.emphasis')]); $rules['.uk-table-divider>tr:not(:first-child), .uk-table-divider>:not(:first-child)>tr, .uk-table-divider>:first-child>tr:not(:first-child), .uk-table-striped>tr:nth-of-type(odd), .uk-table-striped tbody tr:nth-of-type(odd)'] = $decl([ 'border-top-color' => $v('color.border'), ]); $rules['.uk-accordion-title, .uk-h1, .uk-h2, .uk-h3, .uk-h4'] = $decl(['color' => $v('color.emphasis')]); $rules['hr, .uk-hr, .uk-divider-icon::before, .uk-heading-divider'] = $decl(['border-color' => $v('color.border')]); $rules['.uk-search-input, .uk-search-default .uk-search-input'] = $decl(['border-radius' => $radius]); $rules['blockquote'] = $decl(['color' => $v('color.emphasis'), 'border-left-color' => $primary]); $css = ''; foreach ($rules as $selector => $body) { if ($body === '') { continue; } // Every selector in the list gets the scope, not just the first one, // or the demo styles leak out and restyle the page around it. $scoped = array_map( fn(string $part) => trim($scope . ' ' . trim($part)), explode(',', $selector) ?: [''] ); $css .= implode(',', $scoped) . '{' . $body . "}\n"; } return $css; } public function preview(): string { $colors = $this->tokens->group('color'); $type = $this->tokens->group('typography'); $shadow = $this->tokens->group('shadow'); $roles = []; foreach ($this->resolved['mapped'] as $item) { $roles[$item['group'] . '.' . $item['key']] = $item['role']; } // UIkit first, then the page's own chrome, then the token overrides scoped // to .uk-preview so the demo cannot restyle the inspector around it. $html = '' . htmlspecialchars($this->tokens->data['name']) . '' . '' . '' . '' . '' . ''; $html .= '

' . htmlspecialchars($this->tokens->data['name']) . '

'; $html .= $this->previewComponents(); if ($colors) { $html .= '

Color

'; foreach ($colors as $key => $value) { $role = $roles['color.' . $key] ?? ''; $html .= '
' . '
' . htmlspecialchars($key) . '
' . htmlspecialchars($value) . '
' . '
' . htmlspecialchars($role) . '
'; } $html .= '
'; } if ($type) { $html .= '

Typography

'; foreach ($type as $key => $style) { $css = ''; foreach (['fontFamily' => 'font-family', 'fontSize' => 'font-size', 'fontWeight' => 'font-weight', 'lineHeight' => 'line-height', 'letterSpacing' => 'letter-spacing'] as $prop => $name) { if (isset($style[$prop])) { $css .= $name . ':' . htmlspecialchars($style[$prop], ENT_QUOTES) . ';'; } } $html .= '
' . htmlspecialchars($key) . ' — The quick brown fox
' . '
' . htmlspecialchars($roles['typography.' . $key] ?? '') . '
'; } } if ($shadow) { $html .= '

Shadow

'; foreach ($shadow as $key => $value) { $html .= '
' . '
' . htmlspecialchars($key) . '
'; } $html .= '
'; } if ($this->resolved['unmapped']) { $html .= '

Unmapped (' . count($this->resolved['unmapped']) . ')

'; foreach ($this->resolved['unmapped'] as $item) { $html .= htmlspecialchars($item['group'] . '.' . $item['key'] . ' — ' . $item['reason']) . '
'; } $html .= '
'; } return $html; } /** * The full UIkit component set, rendered against the imported style so every * surface can be judged before anything is installed — not a token swatch * sheet, an actual theme proof. Static markup plus UIkit's own JS, so * dropdowns, tabs, accordions and modals really open. */ private function previewComponents(): string { $sections = [ 'Typography' => $this->demoTypography(), 'Buttons' => $this->demoButtons(), 'Navigation' => $this->demoNavigation(), 'Dropdowns' => $this->demoDropdowns(), 'Cards & tiles' => $this->demoCards(), 'Forms' => $this->demoForms(), 'Tables & lists' => $this->demoTables(), 'Feedback' => $this->demoFeedback(), 'Interactive' => $this->demoInteractive(), 'Section styles' => $this->demoSections(), 'Tokens applied' => $this->demoScales(), ]; $out = '
'; foreach ($sections as $title => $markup) { $out .= '
' . htmlspecialchars($title) . '
' . $markup . '
'; } return $out . '
'; } private function demoTypography(): string { return <<<'HTML'

Heading one

Heading two

Heading three

Heading four

Heading five
Heading six

Lead paragraph: the type scale carried over from the design.

Body copy with a text link, bold, italic, inline code and muted text.

Meta text — captions, bylines, timestamps.

A pull quote, styled from the emphasis colour and the brand accent.

Someone, somewhere

Heading with divider

Primary · Emphasis · Success · Danger

HTML; } private function demoButtons(): string { return <<<'HTML'

HTML; } private function demoNavigation(): string { return <<<'HTML'
HTML; } private function demoDropdowns(): string { return <<<'HTML'

Free-form dropdown content.

HTML; } private function demoCards(): string { return <<<'HTML'

Default card

Surface, radius, shadow and card typography from the imported style.

Read more

Primary card

Brand background with the contrast colour resolved for legibility.

Secondary card

Secondary background.

Header

Card with header and footer.

Badge

Hover card

Hover for the large shadow token.

HTML; } private function demoForms(): string { return <<<'HTML'

HTML; } private function demoTables(): string { return <<<'HTML'
Divider, hover, small
ColumnColumnColumn
Row oneValueLabel
Row twoValueSuccess
Row threeValueDanger
  • List divider
  • Second
  • Third
  • List striped
  • Second
  • Third
Description term
Description details.
Second term
More details.
HTML; } private function demoFeedback(): string { return <<<'HTML'

Primary alert.

Success alert.

Warning alert.

Danger alert.

3 Label Success Warning Danger

Hover for a tooltip

HTML; } private function demoInteractive(): string { return <<<'HTML'

Modal title

Dialog surface, radius and shadow.

HTML; } private function demoSections(): string { return <<<'HTML'

Section default

Page surface.

Section muted

Muted surface, for alternating bands.

Section primary

Brand band — what an imported layout refers to as "style": "primary".

Section secondary

Secondary band.

HTML; } /** The raw scales, so a value can be checked directly against its component. */ private function demoScales(): string { $out = '
'; foreach ($this->tokens->group('shadow') as $key => $value) { $out .= '
' . htmlspecialchars($key) . '
'; } foreach ($this->tokens->group('radius') as $key => $value) { $out .= '
' . htmlspecialchars($key) . '
'; } foreach ($this->tokens->group('spacing') as $key => $value) { $out .= '
' . htmlspecialchars($key . ' ' . $value) . '
'; } return $out . '
'; } /** Writes the package tree. Returns the absolute path of the package root. */ public function write(string $dir): string { $child = $dir . '/yootheme_child_' . $this->slug(); foreach ([$dir, $dir . '/preview', $child, $child . '/less', $child . '/css', $child . '/assets'] as $path) { if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) { throw new \RuntimeException("Cannot create directory: $path"); } } file_put_contents($dir . '/manifest.json', json_encode($this->manifest(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); file_put_contents($dir . '/tokens.json', $this->tokens->toJson()); file_put_contents($dir . '/mappings.json', json_encode($this->mappings(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); file_put_contents($dir . '/preview/index.html', $this->preview()); file_put_contents($child . '/templateDetails.xml', $this->templateDetails()); file_put_contents($child . '/less/theme.less', $this->less()); // Keeps declared folders in the zip even when empty — templateDetails.xml lists them. file_put_contents($child . '/assets/index.html', ''); file_put_contents($child . '/css/index.html', ''); if ($this->stylesheet !== '') { file_put_contents( $child . '/css/theme.css', "/* Imported stylesheet — layout and component CSS the Less variables cannot express. */\n" . $this->rewriteAssetUrls($this->stylesheet) . "\n" ); } foreach ($this->assetFiles as $name => $source) { $name = preg_replace('/[^A-Za-z0-9_.\-]/', '', (string) $name); if ($name !== '' && is_file($source)) { copy($source, $child . '/assets/' . $name); } } foreach ($this->tokens->data['assets'] as $asset) { // An entry with no inline data is a manifest row for a file copied above; // writing it here would truncate that copy. if ($asset['data'] === '') { continue; } $data = $asset['type'] === 'svg' ? $asset['data'] : base64_decode($asset['data'], true); if ($data !== false && $asset['name'] !== '') { file_put_contents($child . '/assets/' . $asset['name'], $data); } } return $child; } public function zip(string $dir, string $zipPath): string { $child = $this->write($dir); $zip = new \ZipArchive(); if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException("Cannot create zip: $zipPath"); } $base = strlen($child) + 1; $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($child, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { $zip->addFile($file->getPathname(), substr($file->getPathname(), $base)); } $zip->close(); return $zipPath; } /** Source trees nest images; the child theme flattens them into assets/. */ private function rewriteAssetUrls(string $css): string { return (string) preg_replace_callback( '~url\(\s*([\'"]?)([^)\'"]+)\1\s*\)~i', function (array $m): string { $file = basename(explode('?', $m[2])[0]); if (preg_match('~^(data:|https?:|//)~i', $m[2]) || !isset($this->assetFiles[$file])) { return $m[0]; } return 'url("../assets/' . $file . '")'; }, $css ); } private function dash(string $prop): string { return strtolower(preg_replace('~(?