UIKitV3-Automator / bridge /src /Tokens.php
KyklosIO's picture
Claude Opus 5
Rebuild as the YOOtheme Style Generator
9ec9295
Raw History Blame Contribute Delete
5.77 kB
<?php
/**
* Design token graph. Framework-free on purpose: the Joomla component, the CLI
* and any future automation all load tokens through this one class.
*/
namespace Fyoo\FigmaBridge;
defined('_FYOO_BRIDGE') || define('_FYOO_BRIDGE', 1);
class Tokens
{
public const GROUPS = ['color', 'typography', 'spacing', 'radius', 'shadow', 'breakpoints'];
public array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public static function fromJson(string $json): self
{
$data = json_decode($json, true);
if (!is_array($data)) {
throw new \InvalidArgumentException('Token payload is not valid JSON.');
}
return new self(self::validate($data));
}
public static function fromFile(string $path): self
{
if (!is_readable($path)) {
throw new \InvalidArgumentException("Cannot read token file: $path");
}
return self::fromJson((string) file_get_contents($path));
}
/** Trust boundary: everything below arrives from a Figma plugin over the network. */
public static function validate(array $data): array
{
foreach (['name', 'source', 'tokens'] as $key) {
if (!isset($data[$key])) {
throw new \InvalidArgumentException("Missing required key: $key");
}
}
if (!is_array($data['tokens'])) {
throw new \InvalidArgumentException('tokens must be an object.');
}
$clean = [
'name' => self::text($data['name'], 120),
'source' => in_array($data['source'], ['figma', 'manual', 'api'], true) ? $data['source'] : 'manual',
'version' => self::text($data['version'] ?? '0.1', 20),
'generatedAt' => self::text($data['generatedAt'] ?? gmdate('c'), 40),
'fileKey' => self::text($data['fileKey'] ?? '', 64),
'tokens' => [],
'components' => [],
'assets' => [],
];
foreach (self::GROUPS as $group) {
$clean['tokens'][$group] = [];
foreach ((array) ($data['tokens'][$group] ?? []) as $key => $value) {
$key = self::key((string) $key);
if ($key === '') {
continue;
}
$value = $group === 'typography' ? self::typography($value) : self::value($group, $value);
if ($value !== null) {
$clean['tokens'][$group][$key] = $value;
}
}
}
foreach ((array) ($data['components'] ?? []) as $name => $def) {
$clean['components'][self::text((string) $name, 120)] = [
'role' => self::key((string) ($def['role'] ?? '')),
'target' => self::key((string) ($def['target'] ?? '')),
'overrides' => array_map([self::class, 'cssValue'], (array) ($def['overrides'] ?? [])),
];
}
foreach ((array) ($data['assets'] ?? []) as $asset) {
if (!isset($asset['name'])) {
continue;
}
$type = strtolower((string) ($asset['type'] ?? 'svg'));
if (!in_array($type, ['svg', 'png', 'jpg'], true)) {
continue;
}
$clean['assets'][] = [
'name' => preg_replace('/[^A-Za-z0-9_.\-]/', '', (string) $asset['name']),
'type' => $type,
'data' => (string) ($asset['data'] ?? ''),
];
}
return $clean;
}
public function group(string $name): array
{
return $this->data['tokens'][$name] ?? [];
}
public function count(): int
{
return array_sum(array_map('count', $this->data['tokens']));
}
public function toJson(): string
{
return json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
private static function text(string $value, int $max): string
{
return substr(trim(strip_tags($value)), 0, $max);
}
private static function key(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[\s_/]+~', '-', $value);
return trim(preg_replace('~[^a-z0-9.\-]~', '', (string) $value), '-');
}
/** Rejects anything that could escape the Less declaration it is written into. */
public static function cssValue($value): ?string
{
if (is_numeric($value)) {
return (string) $value;
}
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || strlen($value) > 200 || preg_match('~[{};@\\\\]|/\*|url\s*\(|expression~i', $value)) {
return null;
}
return $value;
}
private static function value(string $group, $value): ?string
{
$value = self::cssValue($value);
if ($value === null) {
return null;
}
if ($group === 'color' && !preg_match('~^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%deg]+\))$~i', $value)) {
return null;
}
return $value;
}
private static function typography($value): ?array
{
if (!is_array($value)) {
return null;
}
$out = [];
foreach (['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textTransform'] as $prop) {
if (isset($value[$prop])) {
$clean = self::cssValue($value[$prop]);
if ($clean !== null) {
$out[$prop] = $clean;
}
}
}
return $out ?: null;
}
}