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; } }