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.
- Bulleted list item
- Second item
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.
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
| Column | Column | Column |
| Row one | Value | Label |
| Row two | Value | Success |
| Row three | Value | Danger |
- Description term
- Description details.
- Second term
- More details.
HTML;
}
private function demoFeedback(): string
{
return <<<'HTML'
3
Label
Success
Warning
Danger
Hover for a tooltip
HTML;
}
private function demoInteractive(): string
{
return <<<'HTML'
- Content for the first tab.
- Content for the second tab.
- Content for the third tab.
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('~(?