UIKitV3-Automator / bridge /src /Compiler.php
KyklosIO's picture
feat: add Intelligent Workflow Elements tab, Dockerfile, presets, and patches installer
cc14d64
Raw History Blame Contribute Delete
42.5 kB
<?php
/**
* Compiles a token graph into a YOOtheme Pro child theme.
*
* Child themes are YOOtheme's documented extension point for custom Less, so the
* import lands as real style variables the customizer keeps honouring, not as
* overriding CSS bolted on afterwards.
*/
namespace Fyoo\FigmaBridge;
class Compiler
{
private Tokens $tokens;
private Mapper $mapper;
private array $resolved;
private array $assetFiles = [];
private string $stylesheet = '';
public function __construct(Tokens $tokens, ?Mapper $mapper = null)
{
$this->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 <<<XML
<?xml version="1.0" encoding="utf-8"?>
<extension type="template" client="site" method="upgrade">
<name>yootheme_child_{$slug}</name>
<version>1.0.0</version>
<creationDate>{$this->today()}</creationDate>
<author>Figma → YOOtheme Style Bridge</author>
<description>{$name} design system, imported from Figma.</description>
<inheritable>0</inheritable>
<parent>yootheme</parent>
<files>
<folder>less</folder>
<folder>css</folder>
<folder>assets</folder>
</files>
</extension>
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 = '<!doctype html><meta charset="utf-8"><title>' . htmlspecialchars($this->tokens->data['name'])
. '</title><meta name="viewport" content="width=device-width, initial-scale=1">'
. '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3/dist/css/uikit.min.css">'
. '<script src="https://cdn.jsdelivr.net/npm/uikit@3/dist/js/uikit.min.js"></script>'
. '<script src="https://cdn.jsdelivr.net/npm/uikit@3/dist/js/uikit-icons.min.js"></script>'
. '<style>body{font:14px/1.5 system-ui,sans-serif;margin:24px;color:#111;background:#fff}'
. 'h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:#666;margin:28px 0 10px}'
. '.g{display:flex;flex-wrap:wrap;gap:10px}.s{width:132px}.c{height:56px;border-radius:6px;border:1px solid #0001}'
. '.k{font:11px ui-monospace,monospace;margin-top:4px}.r{font:10px ui-monospace,monospace;color:#0a7}'
. '.b{padding:16px;border:1px solid #eee;border-radius:8px;margin-bottom:10px}'
. '.uk-preview{padding:20px;border:1px solid #eee;border-radius:8px}'
. '.demo-group{padding:18px 0;border-top:1px solid #0000000f}'
. '.demo-group:first-child{border-top:0}'
. '.demo-label{position:sticky;top:0;z-index:5;margin:0 0 12px;padding:4px 8px;'
. 'font:600 10px/1.6 ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;'
. 'color:#666;background:#f6f6f6;border-radius:4px;display:inline-block}'
. '.demo-scale{display:flex;flex-wrap:wrap;gap:12px}'
. '.demo-chip{padding:12px 16px;font:11px ui-monospace,monospace;border-radius:6px}</style>'
. '<style>' . $this->previewCss() . '</style>';
$html .= '<h1>' . htmlspecialchars($this->tokens->data['name']) . '</h1>';
$html .= $this->previewComponents();
if ($colors) {
$html .= '<h2>Color</h2><div class="g">';
foreach ($colors as $key => $value) {
$role = $roles['color.' . $key] ?? '';
$html .= '<div class="s"><div class="c" style="background:' . htmlspecialchars($value) . '"></div>'
. '<div class="k">' . htmlspecialchars($key) . '<br>' . htmlspecialchars($value) . '</div>'
. '<div class="r">' . htmlspecialchars($role) . '</div></div>';
}
$html .= '</div>';
}
if ($type) {
$html .= '<h2>Typography</h2>';
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 .= '<div class="b"><div style="' . $css . '">' . htmlspecialchars($key) . ' — The quick brown fox</div>'
. '<div class="r">' . htmlspecialchars($roles['typography.' . $key] ?? '') . '</div></div>';
}
}
if ($shadow) {
$html .= '<h2>Shadow</h2><div class="g">';
foreach ($shadow as $key => $value) {
$html .= '<div class="s"><div class="c" style="background:#fff;box-shadow:' . htmlspecialchars($value, ENT_QUOTES) . '"></div>'
. '<div class="k">' . htmlspecialchars($key) . '</div></div>';
}
$html .= '</div>';
}
if ($this->resolved['unmapped']) {
$html .= '<h2>Unmapped (' . count($this->resolved['unmapped']) . ')</h2><div class="k">';
foreach ($this->resolved['unmapped'] as $item) {
$html .= htmlspecialchars($item['group'] . '.' . $item['key'] . ' — ' . $item['reason']) . '<br>';
}
$html .= '</div>';
}
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 = '<div class="uk-preview">';
foreach ($sections as $title => $markup) {
$out .= '<div class="demo-group"><div class="demo-label">' . htmlspecialchars($title) . '</div>' . $markup . '</div>';
}
return $out . '</div>';
}
private function demoTypography(): string
{
return <<<'HTML'
<h1>Heading one</h1>
<h2>Heading two</h2>
<h3>Heading three</h3>
<h4>Heading four</h4>
<h5>Heading five</h5>
<h6>Heading six</h6>
<p class="uk-text-lead">Lead paragraph: the type scale carried over from the design.</p>
<p>Body copy with a <a href="#">text link</a>, <strong>bold</strong>, <em>italic</em>,
<code>inline code</code> and <span class="uk-text-muted">muted text</span>.</p>
<p class="uk-text-meta">Meta text — captions, bylines, timestamps.</p>
<blockquote><p>A pull quote, styled from the emphasis colour and the brand accent.</p>
<footer>Someone, <cite>somewhere</cite></footer></blockquote>
<ul class="uk-list uk-list-bullet"><li>Bulleted list item</li><li>Second item</li></ul>
<hr>
<h2 class="uk-heading-divider">Heading with divider</h2>
<p><span class="uk-text-primary">Primary</span> · <span class="uk-text-emphasis">Emphasis</span> ·
<span class="uk-text-success">Success</span> · <span class="uk-text-danger">Danger</span></p>
HTML;
}
private function demoButtons(): string
{
return <<<'HTML'
<p>
<button class="uk-button uk-button-primary" type="button">Primary</button>
<button class="uk-button uk-button-secondary" type="button">Secondary</button>
<button class="uk-button uk-button-default" type="button">Default</button>
<button class="uk-button uk-button-danger" type="button">Danger</button>
<button class="uk-button uk-button-text" type="button">Text</button>
<button class="uk-button uk-button-link" type="button">Link</button>
</p>
<p>
<button class="uk-button uk-button-primary uk-button-small" type="button">Small</button>
<button class="uk-button uk-button-primary" type="button">Default size</button>
<button class="uk-button uk-button-primary uk-button-large" type="button">Large</button>
<button class="uk-button uk-button-primary" type="button" disabled>Disabled</button>
</p>
<p>
<div class="uk-button-group">
<button class="uk-button uk-button-secondary" type="button">Group</button>
<button class="uk-button uk-button-secondary" type="button">of</button>
<button class="uk-button uk-button-secondary" type="button">buttons</button>
</div>
</p>
<p>
<a class="uk-icon-button" href="#" uk-icon="heart"></a>
<a class="uk-icon-button" href="#" uk-icon="search"></a>
<button class="uk-button uk-button-primary" type="button"><span uk-icon="check"></span> With icon</button>
</p>
HTML;
}
private function demoNavigation(): string
{
return <<<'HTML'
<div class="uk-navbar-container uk-margin-bottom">
<div class="uk-container">
<nav class="uk-navbar" uk-navbar>
<div class="uk-navbar-left">
<a class="uk-navbar-item uk-logo" href="#">Brand</a>
<ul class="uk-navbar-nav">
<li class="uk-active"><a href="#">Active</a></li>
<li><a href="#">Parent <span uk-navbar-parent-icon></span></a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-active"><a href="#">Active item</a></li>
<li><a href="#">Second item</a></li>
<li class="uk-nav-header">Header</li>
<li><a href="#">Third item</a></li>
<li class="uk-nav-divider"></li>
<li><a href="#">After divider</a></li>
</ul>
</div>
</li>
<li><a href="#">Item</a></li>
</ul>
</div>
<div class="uk-navbar-right">
<a class="uk-navbar-toggle" href="#" uk-search-icon></a>
<a class="uk-button uk-button-primary uk-margin-small-left" href="#">Call to action</a>
</div>
</nav>
</div>
</div>
<div class="uk-child-width-1-2@m uk-grid-small" uk-grid>
<div>
<ul class="uk-nav uk-nav-default">
<li class="uk-active"><a href="#">Nav default active</a></li>
<li class="uk-parent"><a href="#">Parent</a>
<ul class="uk-nav-sub"><li><a href="#">Sub item</a></li><li><a href="#">Sub item</a></li></ul>
</li>
<li class="uk-nav-header">Header</li>
<li><a href="#">Item</a></li>
<li class="uk-nav-divider"></li>
<li><a href="#">Item</a></li>
</ul>
</div>
<div>
<ul class="uk-nav uk-nav-primary">
<li class="uk-active"><a href="#">Nav primary</a></li>
<li><a href="#">Second</a></li>
<li><a href="#">Third</a></li>
</ul>
</div>
</div>
<ul class="uk-subnav" uk-margin><li class="uk-active"><a href="#">Subnav</a></li><li><a href="#">Item</a></li><li><a href="#">Item</a></li></ul>
<ul class="uk-subnav uk-subnav-divider"><li class="uk-active"><a href="#">Divider</a></li><li><a href="#">Item</a></li><li><a href="#">Item</a></li></ul>
<ul class="uk-subnav uk-subnav-pill"><li class="uk-active"><a href="#">Pill</a></li><li><a href="#">Item</a></li><li><a href="#">Item</a></li></ul>
<ul class="uk-breadcrumb"><li><a href="#">Home</a></li><li><a href="#">Section</a></li><li class="uk-active"><span>Current page</span></li></ul>
<ul class="uk-pagination">
<li><a href="#"><span uk-pagination-previous></span></a></li>
<li><a href="#">1</a></li><li class="uk-active"><span>2</span></li><li><a href="#">3</a></li>
<li class="uk-pagination-next"><a href="#"><span uk-pagination-next></span></a></li>
</ul>
HTML;
}
private function demoDropdowns(): string
{
return <<<'HTML'
<div class="uk-grid-small uk-flex-middle" uk-grid>
<div class="uk-width-auto">
<button class="uk-button uk-button-default" type="button">Dropdown</button>
<div uk-dropdown="mode: click">
<ul class="uk-nav uk-dropdown-nav">
<li class="uk-active"><a href="#">Active</a></li>
<li><a href="#">Item</a></li>
<li class="uk-nav-header">Header</li>
<li><a href="#">Item</a></li>
<li class="uk-nav-divider"></li>
<li><a href="#">Item</a></li>
</ul>
</div>
</div>
<div class="uk-width-auto">
<button class="uk-button uk-button-primary" type="button">Primary dropdown</button>
<div uk-dropdown="mode: click; pos: bottom-right">
<ul class="uk-nav uk-dropdown-nav"><li><a href="#">Profile</a></li><li><a href="#">Settings</a></li><li><a href="#">Sign out</a></li></ul>
</div>
</div>
<div class="uk-width-auto">
<span uk-icon="more-vertical" class="uk-icon-button"></span>
<div uk-dropdown="mode: click"><p class="uk-margin-remove">Free-form dropdown content.</p></div>
</div>
<div class="uk-width-auto">
<select class="uk-select" style="width:200px"><option>Native select</option><option>Second option</option></select>
</div>
</div>
HTML;
}
private function demoCards(): string
{
return <<<'HTML'
<div class="uk-child-width-1-3@m uk-grid-match" uk-grid>
<div>
<div class="uk-card uk-card-default uk-card-body">
<h3 class="uk-card-title">Default card</h3>
<p>Surface, radius, shadow and card typography from the imported style.</p>
<a href="#" class="uk-button uk-button-text">Read more</a>
</div>
</div>
<div>
<div class="uk-card uk-card-primary uk-card-body">
<h3 class="uk-card-title">Primary card</h3>
<p>Brand background with the contrast colour resolved for legibility.</p>
</div>
</div>
<div>
<div class="uk-card uk-card-secondary uk-card-body">
<h3 class="uk-card-title">Secondary card</h3>
<p>Secondary background.</p>
</div>
</div>
</div>
<div class="uk-child-width-1-2@m uk-margin" uk-grid>
<div>
<div class="uk-card uk-card-default">
<div class="uk-card-header"><h4 class="uk-margin-remove">Header</h4></div>
<div class="uk-card-body"><p>Card with header and footer.</p></div>
<div class="uk-card-footer"><a href="#" class="uk-button uk-button-text">Footer action</a></div>
</div>
</div>
<div>
<div class="uk-card uk-card-default uk-card-body uk-card-hover">
<div class="uk-card-badge uk-label">Badge</div>
<h4>Hover card</h4><p>Hover for the large shadow token.</p>
</div>
</div>
</div>
HTML;
}
private function demoForms(): string
{
return <<<'HTML'
<form class="uk-form-stacked">
<div class="uk-child-width-1-2@m uk-grid-small" uk-grid>
<div>
<label class="uk-form-label" for="d-in">Text input</label>
<div class="uk-form-controls"><input class="uk-input" id="d-in" type="text" placeholder="Placeholder"></div>
</div>
<div>
<label class="uk-form-label" for="d-sel">Select</label>
<div class="uk-form-controls"><select class="uk-select" id="d-sel"><option>Option one</option><option>Option two</option></select></div>
</div>
<div>
<label class="uk-form-label" for="d-ta">Textarea</label>
<div class="uk-form-controls"><textarea class="uk-textarea" id="d-ta" rows="3" placeholder="Message"></textarea></div>
</div>
<div>
<label class="uk-form-label" for="d-search">Search</label>
<div class="uk-form-controls"><div class="uk-search uk-search-default uk-width-1-1"><span uk-search-icon></span><input class="uk-search-input" id="d-search" type="search" placeholder="Search"></div></div>
</div>
</div>
<div class="uk-margin">
<label><input class="uk-checkbox" type="checkbox" checked> Checkbox</label>
<label class="uk-margin-left"><input class="uk-radio" type="radio" name="d-r" checked> Radio one</label>
<label><input class="uk-radio" type="radio" name="d-r"> Radio two</label>
</div>
<div class="uk-margin">
<input class="uk-range" type="range" value="60" min="0" max="100">
</div>
<div class="uk-margin uk-grid-small uk-child-width-1-3@s" uk-grid>
<div><input class="uk-input uk-form-small" type="text" placeholder="Small"></div>
<div><input class="uk-input uk-form-danger" type="text" value="Danger state"></div>
<div><input class="uk-input uk-form-success" type="text" value="Success state"></div>
</div>
<p><button class="uk-button uk-button-primary" type="button">Submit</button>
<button class="uk-button uk-button-default" type="button">Cancel</button></p>
</form>
HTML;
}
private function demoTables(): string
{
return <<<'HTML'
<table class="uk-table uk-table-divider uk-table-hover uk-table-small">
<caption>Divider, hover, small</caption>
<thead><tr><th>Column</th><th>Column</th><th>Column</th></tr></thead>
<tbody>
<tr><td>Row one</td><td>Value</td><td><span class="uk-label">Label</span></td></tr>
<tr><td>Row two</td><td>Value</td><td><span class="uk-label uk-label-success">Success</span></td></tr>
<tr><td>Row three</td><td>Value</td><td><span class="uk-label uk-label-danger">Danger</span></td></tr>
</tbody>
</table>
<div class="uk-child-width-1-3@m uk-grid-small" uk-grid>
<div><ul class="uk-list uk-list-divider"><li>List divider</li><li>Second</li><li>Third</li></ul></div>
<div><ul class="uk-list uk-list-striped"><li>List striped</li><li>Second</li><li>Third</li></ul></div>
<div>
<dl class="uk-description-list uk-description-list-divider">
<dt>Description term</dt><dd>Description details.</dd>
<dt>Second term</dt><dd>More details.</dd>
</dl>
</div>
</div>
HTML;
}
private function demoFeedback(): string
{
return <<<'HTML'
<div class="uk-alert uk-alert-primary" uk-alert><a class="uk-alert-close" uk-close></a><p>Primary alert.</p></div>
<div class="uk-alert uk-alert-success" uk-alert><a class="uk-alert-close" uk-close></a><p>Success alert.</p></div>
<div class="uk-alert uk-alert-warning" uk-alert><a class="uk-alert-close" uk-close></a><p>Warning alert.</p></div>
<div class="uk-alert uk-alert-danger" uk-alert><a class="uk-alert-close" uk-close></a><p>Danger alert.</p></div>
<p>
<span class="uk-badge">3</span>
<span class="uk-label">Label</span>
<span class="uk-label uk-label-success">Success</span>
<span class="uk-label uk-label-warning">Warning</span>
<span class="uk-label uk-label-danger">Danger</span>
<span class="uk-marker" uk-marker></span>
<span uk-spinner></span>
</p>
<progress class="uk-progress" value="65" max="100"></progress>
<p><a href="#" title="Tooltip text" uk-tooltip>Hover for a tooltip</a></p>
HTML;
}
private function demoInteractive(): string
{
return <<<'HTML'
<ul class="uk-tab" uk-tab>
<li class="uk-active"><a href="#">Tab one</a></li>
<li><a href="#">Tab two</a></li>
<li><a href="#">Tab three</a></li>
</ul>
<ul class="uk-switcher uk-margin">
<li>Content for the first tab.</li><li>Content for the second tab.</li><li>Content for the third tab.</li>
</ul>
<ul uk-accordion>
<li class="uk-open"><a class="uk-accordion-title" href="#">Accordion — open</a>
<div class="uk-accordion-content"><p>Panel content in body copy.</p></div></li>
<li><a class="uk-accordion-title" href="#">Accordion — closed</a>
<div class="uk-accordion-content"><p>Second panel.</p></div></li>
</ul>
<p>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #demo-modal">Open modal</button>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #demo-offcanvas">Open offcanvas</button>
</p>
<div id="demo-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body">
<h2 class="uk-modal-title">Modal title</h2>
<p>Dialog surface, radius and shadow.</p>
<p class="uk-text-right"><button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
<button class="uk-button uk-button-primary" type="button">Save</button></p>
</div>
</div>
<div id="demo-offcanvas" uk-offcanvas>
<div class="uk-offcanvas-bar">
<button class="uk-offcanvas-close" type="button" uk-close></button>
<ul class="uk-nav uk-nav-primary"><li class="uk-active"><a href="#">Menu item</a></li><li><a href="#">Second</a></li></ul>
</div>
</div>
HTML;
}
private function demoSections(): string
{
return <<<'HTML'
<div class="uk-section uk-section-default uk-padding-small"><div class="uk-container"><h4 class="uk-margin-remove">Section default</h4><p class="uk-margin-remove">Page surface.</p></div></div>
<div class="uk-section uk-section-muted uk-padding-small"><div class="uk-container"><h4 class="uk-margin-remove">Section muted</h4><p class="uk-margin-remove">Muted surface, for alternating bands.</p></div></div>
<div class="uk-section uk-section-primary uk-padding-small"><div class="uk-container"><h4 class="uk-margin-remove">Section primary</h4><p class="uk-margin-remove">Brand band — what an imported layout refers to as <code>"style": "primary"</code>.</p></div></div>
<div class="uk-section uk-section-secondary uk-padding-small"><div class="uk-container"><h4 class="uk-margin-remove">Section secondary</h4><p class="uk-margin-remove">Secondary band.</p></div></div>
HTML;
}
/** The raw scales, so a value can be checked directly against its component. */
private function demoScales(): string
{
$out = '<div class="demo-scale">';
foreach ($this->tokens->group('shadow') as $key => $value) {
$out .= '<div class="demo-chip" style="box-shadow:' . htmlspecialchars($value, ENT_QUOTES) . '">'
. htmlspecialchars($key) . '</div>';
}
foreach ($this->tokens->group('radius') as $key => $value) {
$out .= '<div class="demo-chip" style="border:1px solid currentColor;border-radius:'
. htmlspecialchars($value, ENT_QUOTES) . '">' . htmlspecialchars($key) . '</div>';
}
foreach ($this->tokens->group('spacing') as $key => $value) {
$out .= '<div class="demo-chip" style="padding-left:' . htmlspecialchars($value, ENT_QUOTES)
. ';max-width:100%">' . htmlspecialchars($key . ' ' . $value) . '</div>';
}
return $out . '</div>';
}
/** 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', '<!doctype html><title></title>');
file_put_contents($child . '/css/index.html', '<!doctype html><title></title>');
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('~(?<!^)[A-Z]~', '-$0', $prop));
}
private function quote(string $prop, string $value): string
{
return $prop === 'fontFamily' && !preg_match('~^["\']~', $value) ? '"' . $value . '"' : $value;
}
private function today(): string
{
return gmdate('F Y');
}
}