Spaces:
Sleeping
Sleeping
Download bridge/src/Extractor.php from acecalisto3/UIKitV3-Automator: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Extractor.php
- Command line
-
hf download hf://spaces/acecalisto3/UIKitV3-Automator/bridge/src/Extractor.php
-
curl -L -o Extractor.php https://huggingface.co/spaces/acecalisto3/UIKitV3-Automator/resolve/main/bridge/src/Extractor.php
13.9 kB
| /** | |
| * Extracts a design token graph from a built static site (HTML + CSS + images). | |
| * | |
| * The other direction from the Figma plugin: Figma hands us named variables, a | |
| * site hands us usage. So roles are inferred from where a value is *used* — | |
| * body background is the surface, the most-used non-neutral section background | |
| * is the brand — rather than from custom-property names, which in real | |
| * stylesheets are things like `--blue`. | |
| */ | |
| namespace Fyoo\FigmaBridge; | |
| class Extractor | |
| { | |
| private string $dir; | |
| private array $rules = []; // [selector, [prop => value]] | |
| private array $vars = []; // :root custom properties, resolved | |
| private array $assets = []; // name => absolute source path | |
| private string $css = ''; | |
| private const IMAGE_TYPES = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'svg' => 'svg']; | |
| public function __construct(string $dir) | |
| { | |
| $real = realpath($dir); | |
| if ($real === false || !is_dir($real)) { | |
| throw new \InvalidArgumentException("Not a directory: $dir"); | |
| } | |
| $this->dir = $real; | |
| $this->scan(); | |
| } | |
| /** Files worth reading, without walking into dependency or VCS trees. */ | |
| private function scan(): void | |
| { | |
| $skip = ['node_modules', '.git', 'vendor', 'dist', '.next', '__MACOSX']; | |
| $iterator = new \RecursiveIteratorIterator( | |
| new \RecursiveCallbackFilterIterator( | |
| new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS), | |
| fn($file) => !($file->isDir() && in_array($file->getFilename(), $skip, true)) | |
| ) | |
| ); | |
| $html = []; | |
| foreach ($iterator as $file) { | |
| $ext = strtolower($file->getExtension()); | |
| if ($ext === 'css') { | |
| $this->css .= "\n" . file_get_contents($file->getPathname()); | |
| } elseif (in_array($ext, ['html', 'htm'], true)) { | |
| $html[] = $file->getPathname(); | |
| } elseif (isset(self::IMAGE_TYPES[$ext]) && $file->getSize() < 5242880) { | |
| // Flatten into one assets folder; the child theme has no nesting. | |
| $name = $file->getBasename(); | |
| $this->assets[$name] = $file->getPathname(); | |
| } | |
| } | |
| // Inline <style> blocks count as stylesheet too. | |
| foreach ($html as $path) { | |
| $markup = (string) file_get_contents($path); | |
| if (preg_match_all('~<style[^>]*>(.*?)</style>~is', $markup, $matches)) { | |
| $this->css .= "\n" . implode("\n", $matches[1]); | |
| } | |
| if ($this->name === null && preg_match('~<title[^>]*>(.*?)</title>~is', $markup, $m)) { | |
| $this->name = trim(html_entity_decode(strip_tags($m[1]))); | |
| } | |
| } | |
| $this->parse(); | |
| } | |
| private ?string $name = null; | |
| private function parse(): void | |
| { | |
| $css = preg_replace('~/\*.*?\*/~s', '', $this->css); | |
| // Rules only: an @media wrapper's body contains braces, so it never matches. | |
| preg_match_all('~([^{}]+)\{([^{}]*)\}~', (string) $css, $matches, PREG_SET_ORDER); | |
| foreach ($matches as $match) { | |
| $selector = trim(preg_replace('~\s+~', ' ', $match[1])); | |
| $decls = []; | |
| foreach (explode(';', $match[2]) as $declaration) { | |
| $parts = explode(':', $declaration, 2); | |
| if (count($parts) === 2 && trim($parts[0]) !== '') { | |
| $decls[strtolower(trim($parts[0]))] = trim($parts[1]); | |
| } | |
| } | |
| if ($decls) { | |
| $this->rules[] = [$selector, $decls]; | |
| } | |
| } | |
| foreach ($this->rules as [$selector, $decls]) { | |
| if (preg_match('~(^|,\s*):root(\s|,|$)~', $selector)) { | |
| foreach ($decls as $prop => $value) { | |
| if (str_starts_with($prop, '--')) { | |
| $this->vars[substr($prop, 2)] = $value; | |
| } | |
| } | |
| } | |
| } | |
| foreach ($this->vars as $key => $value) { | |
| $this->vars[$key] = $this->resolve($value, 0); | |
| } | |
| } | |
| /** Substitutes var() references, including fallbacks. */ | |
| private function resolve(string $value, int $depth = 0): string | |
| { | |
| if ($depth > 5 || !str_contains($value, 'var(')) { | |
| return trim($value); | |
| } | |
| $resolved = preg_replace_callback( | |
| '~var\(\s*--([A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)~', | |
| fn($m) => $this->vars[$m[1]] ?? trim($m[2] ?? ''), | |
| $value | |
| ); | |
| return $this->resolve((string) $resolved, $depth + 1); | |
| } | |
| /** All declarations of one property, in document order, var()-resolved. */ | |
| private function declarations(string $property, ?string $selectorPattern = null): array | |
| { | |
| $found = []; | |
| foreach ($this->rules as [$selector, $decls]) { | |
| if ($selectorPattern !== null && !preg_match($selectorPattern, $selector)) { | |
| continue; | |
| } | |
| if (isset($decls[$property])) { | |
| $found[] = [$selector, $this->resolve($decls[$property])]; | |
| } | |
| } | |
| return $found; | |
| } | |
| private function first(string $property, string $selectorPattern): ?string | |
| { | |
| $found = $this->declarations($property, $selectorPattern); | |
| return $found[0][1] ?? null; | |
| } | |
| private static function color(?string $value): ?string | |
| { | |
| if ($value === null) { | |
| return null; | |
| } | |
| // A shorthand like "0 4px 5px rgba(...)" is not a colour; take a leading colour only. | |
| if (preg_match('~^(#[0-9a-fA-F]{3,8}|rgba?\([^()]*\)|hsla?\([^()]*\))$~', trim($value), $m)) { | |
| return $m[1]; | |
| } | |
| return null; | |
| } | |
| private static function luminance(string $color): float | |
| { | |
| if (preg_match('~^#([0-9a-fA-F]{3})$~', $color, $m)) { | |
| $color = '#' . $m[1][0] . $m[1][0] . $m[1][1] . $m[1][1] . $m[1][2] . $m[1][2]; | |
| } | |
| if (preg_match('~^#([0-9a-fA-F]{6})~', $color, $m)) { | |
| [$r, $g, $b] = array_map('hexdec', str_split($m[1], 2)); | |
| } elseif (preg_match('~rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)~', $color, $m)) { | |
| [, $r, $g, $b] = array_map('intval', $m); | |
| } else { | |
| return 0.5; | |
| } | |
| return (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255; | |
| } | |
| /** Backgrounds ranked by how much of the page they carry, neutrals excluded. */ | |
| private function backgroundRanking(): array | |
| { | |
| $counts = []; | |
| foreach (['background', 'background-color'] as $property) { | |
| foreach ($this->declarations($property) as [$selector, $value]) { | |
| $color = self::color(preg_split('~\s+~', $value)[0] ?? ''); | |
| if ($color === null || preg_match('~^(html|body|\*)$~', $selector)) { | |
| continue; | |
| } | |
| $counts[$color] = ($counts[$color] ?? 0) + 1; | |
| } | |
| } | |
| arsort($counts); | |
| return $counts; | |
| } | |
| private function headings(): array | |
| { | |
| $levels = []; | |
| foreach (range(1, 6) as $level) { | |
| $style = []; | |
| foreach ($this->rules as [$selector, $decls]) { | |
| if (!preg_match('~(^|[\s,>])h' . $level . '(\s|,|$|\.)~i', $selector)) { | |
| continue; | |
| } | |
| foreach (['font-size' => 'fontSize', 'font-weight' => 'fontWeight', 'line-height' => 'lineHeight', 'letter-spacing' => 'letterSpacing', 'font-family' => 'fontFamily'] as $css => $prop) { | |
| if (isset($decls[$css]) && !isset($style[$prop])) { | |
| $style[$prop] = $this->resolve($decls[$css]); | |
| } | |
| } | |
| } | |
| if (isset($style['fontSize'])) { | |
| $levels[$level] = $style; | |
| } | |
| } | |
| return $levels; | |
| } | |
| private function mostCommon(string $property, callable $filter): ?string | |
| { | |
| $counts = []; | |
| foreach ($this->declarations($property) as [, $value]) { | |
| if ($filter($value)) { | |
| $counts[$value] = ($counts[$value] ?? 0) + 1; | |
| } | |
| } | |
| arsort($counts); | |
| return array_key_first($counts); | |
| } | |
| public function tokens(): Tokens | |
| { | |
| $color = []; | |
| $background = self::color($this->first('background', '~^(body|html)$~')) | |
| ?? self::color($this->first('background-color', '~^(body|html)$~')); | |
| $text = self::color($this->first('color', '~^(body|html)$~')); | |
| $ranking = $this->backgroundRanking(); | |
| $accents = array_keys(array_filter( | |
| $ranking, | |
| fn($c) => self::luminance($c) > 0.06 && self::luminance($c) < 0.80 && $c !== $background, | |
| ARRAY_FILTER_USE_KEY | |
| )); | |
| $surfaces = array_keys(array_filter( | |
| $ranking, | |
| fn($c) => self::luminance($c) >= 0.80 && $c !== $background, | |
| ARRAY_FILTER_USE_KEY | |
| )); | |
| $primary = $accents[0] ?? null; | |
| if ($primary !== null) { | |
| $color['brand-primary'] = $primary; | |
| } | |
| if (isset($accents[1])) { | |
| $color['brand-secondary'] = $accents[1]; | |
| } | |
| if ($background !== null) { | |
| $color['surface'] = $background; | |
| } | |
| // Plain white is never a useful "muted" section background next to a light page. | |
| $muted = array_values(array_filter($surfaces, fn($c) => !in_array(strtolower($c), ['#fff', '#ffffff'], true))); | |
| if (isset($muted[0])) { | |
| $color['surface-muted'] = $muted[0]; | |
| } | |
| if ($text !== null) { | |
| $color['text'] = $text; | |
| } | |
| $headings = $this->headings(); | |
| $headingColor = self::color($this->first('color', '~(^|[\s,>])h[12](\s|,|$|\.)~i')); | |
| if ($headingColor !== null) { | |
| $color['heading'] = $headingColor; | |
| } | |
| if ($primary !== null) { | |
| // Inverse is whatever reads on the brand colour, not a guess about theme. | |
| $color['on-primary'] = self::luminance($primary) > 0.55 ? '#000000' : '#FFFFFF'; | |
| } | |
| $border = self::color($this->mostCommon('border-color', fn($v) => self::color($v) !== null)) | |
| ?? self::color(preg_split('~\s+~', (string) $this->mostCommon('border', fn($v) => (bool) preg_match('~#|rgb~', $v)))[2] ?? ''); | |
| if ($border !== null) { | |
| $color['border-default'] = $border; | |
| } | |
| $typography = []; | |
| $bodyFont = $this->first('font-family', '~^(body|html)$~'); | |
| $bodySize = $this->first('font-size', '~^(body|html)$~'); | |
| $bodyLine = $this->first('line-height', '~^(body|html)$~'); | |
| if ($bodyFont || $bodySize) { | |
| $typography['body'] = array_filter([ | |
| 'fontFamily' => $bodyFont ? trim(explode(',', $bodyFont)[0], " '\"") : null, | |
| 'fontSize' => $bodySize ?: '16px', | |
| 'lineHeight' => $bodyLine ?: '1.5', | |
| ]); | |
| } | |
| $names = [1 => 'heading-xl', 2 => 'heading-lg', 3 => 'heading-md', 4 => 'heading-sm', 5 => 'heading-xs', 6 => 'heading-2xs']; | |
| foreach ($headings as $level => $style) { | |
| if (isset($style['fontFamily'])) { | |
| $style['fontFamily'] = trim(explode(',', $style['fontFamily'])[0], " '\""); | |
| } | |
| $typography[$names[$level]] = $style; | |
| } | |
| $spacing = []; | |
| foreach ($this->vars as $key => $value) { | |
| if (preg_match('~^\d+(\.\d+)?(px|rem|em)$~', trim($value))) { | |
| $spacing[$key] = trim($value); | |
| } | |
| } | |
| $container = $this->mostCommon('max-width', fn($v) => (bool) preg_match('~^\d{3,4}px$~', $v)); | |
| if ($container !== null) { | |
| $spacing['container'] = $container; | |
| } | |
| $radius = []; | |
| $commonRadius = $this->mostCommon('border-radius', fn($v) => (bool) preg_match('~^\d+(px|rem|%)$~', $v) && $v !== '0px'); | |
| if ($commonRadius !== null) { | |
| $radius['card'] = $commonRadius; | |
| } | |
| $shadows = []; | |
| foreach ($this->declarations('box-shadow') as [, $value]) { | |
| if ($value !== 'none' && !isset($shadows[$value])) { | |
| $shadows[$value] = strlen($value); | |
| } | |
| } | |
| asort($shadows); | |
| $shadowList = array_keys($shadows); | |
| $shadow = []; | |
| foreach (['shadow-md', 'shadow-lg', 'shadow-xl'] as $index => $key) { | |
| if (isset($shadowList[$index])) { | |
| $shadow[$key] = $shadowList[$index]; | |
| } | |
| } | |
| $assets = []; | |
| foreach ($this->assets as $name => $path) { | |
| $assets[] = ['name' => $name, 'type' => self::IMAGE_TYPES[strtolower(pathinfo($name, PATHINFO_EXTENSION))], 'data' => '']; | |
| } | |
| return new Tokens(Tokens::validate([ | |
| 'name' => $this->name ?: ucwords(str_replace(['-', '_'], ' ', basename($this->dir))), | |
| 'source' => 'manual', | |
| 'version' => '0.1', | |
| 'generatedAt' => gmdate('c'), | |
| 'tokens' => [ | |
| 'color' => $color, | |
| 'typography' => $typography, | |
| 'spacing' => $spacing, | |
| 'radius' => $radius, | |
| 'shadow' => $shadow, | |
| ], | |
| 'components' => [], | |
| 'assets' => $assets, | |
| ])); | |
| } | |
| /** name => absolute source path. Copied by the caller, which knows the target. */ | |
| public function assets(): array | |
| { | |
| return $this->assets; | |
| } | |
| /** The site's own CSS, for the parts no Less variable can express. */ | |
| public function stylesheet(): string | |
| { | |
| return trim($this->css); | |
| } | |
| } | |