UIKitV3-Automator / bridge /src /Extractor.php
KyklosIO's picture
Claude Opus 5
Import a built site folder as a YOOtheme child theme
080e771
Raw History Blame Contribute Delete
13.9 kB
<?php
/**
* 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);
}
}