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 ~is', $markup, $matches)) { $this->css .= "\n" . implode("\n", $matches[1]); } if ($this->name === null && preg_match('~]*>(.*?)~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); } }