utilikit-1.0.0/src/Service/UtilikitCssGenerator.php
src/Service/UtilikitCssGenerator.php
<?php
declare(strict_types=1);
namespace Drupal\utilikit\Service;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Psr\Log\LoggerInterface;
/**
* Generates CSS from UtiliKit utility classes.
*
* This service converts UtiliKit utility classes into their corresponding CSS
* rules. It supports responsive breakpoints, various value types (colors,
* transforms, grid templates, spacing), caching for performance, and handles
* complex parsing for features like decimal notation and grid track lists.
*/
class UtilikitCssGenerator {
/**
* The configuration factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
public ConfigFactoryInterface $configFactory;
/**
* The cache backend service.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected CacheBackendInterface $cache;
/**
* The content scanner service.
*
* @var \Drupal\utilikit\Service\UtilikitContentScanner
*/
protected UtilikitContentScanner $contentScanner;
/**
* The logger service.
*
* @var \Psr\Log\LoggerInterface
*/
protected LoggerInterface $logger;
/**
* Constructs a new UtilikitCssGenerator object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The configuration factory service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend service.
* @param \Drupal\utilikit\Service\UtilikitContentScanner $contentScanner
* The content scanner service for validation.
* @param \Psr\Log\LoggerInterface $logger
* The logger service for recording CSS generation operations.
*/
public function __construct(
ConfigFactoryInterface $configFactory,
CacheBackendInterface $cache,
UtilikitContentScanner $contentScanner,
LoggerInterface $logger,
) {
$this->configFactory = $configFactory;
$this->cache = $cache;
$this->contentScanner = $contentScanner;
$this->logger = $logger;
}
/**
* Generates CSS from an array of utility classes.
*
* Processes utility classes to generate corresponding CSS rules, organized
* by breakpoints and cached for performance. Handles validation, filtering,
* and responsive media query generation.
*
* @param array $classes
* Array of utility class names to process.
*
* @return string
* Generated CSS string with media queries for responsive classes.
*/
public function generateCssFromClasses(array $classes): string {
// Filter out null, empty, and non-string values FIRST.
$classes = array_filter($classes, static function ($class) {
return is_string($class) && !empty($class);
});
// Remove duplicates.
$classes = array_unique($classes);
if (count($classes) > UtilikitConstants::MAX_CLASSES_WARNING_THRESHOLD) {
$this->logger->error('Class count exceeds hard limit: @count > @limit', [
'@count' => count($classes),
'@limit' => UtilikitConstants::MAX_CLASSES_WARNING_THRESHOLD,
]);
throw new \RuntimeException('Too many utility classes to process. Please clean up unused classes.');
}
sort($classes);
$cid = 'utilikit:css:' . md5(serialize($classes));
if ($cache = $this->cache->get($cid)) {
return $cache->data;
}
$rules = UtilikitRules::getRules();
$cssRules = [
'base' => [],
'sm' => [],
'md' => [],
'lg' => [],
'xl' => [],
'xxl' => [],
];
$processedClasses = [
'base' => [],
'sm' => [],
'md' => [],
'lg' => [],
'xl' => [],
'xxl' => [],
];
foreach ($classes as $class) {
// Double-check even after filtering (defensive programming).
if (empty($class) || !is_string($class)) {
continue;
}
if (!$this->contentScanner->isValidUtilityClass($class)) {
continue;
}
$pattern = '/^uk-(?:(sm|md|lg|xl|xxl)-)?(\w+)--(.+)$/';
if (!preg_match($pattern, $class, $matches)) {
continue;
}
$breakpoint = $matches[1] ?: NULL;
$prefix = $matches[2];
$suffix = $matches[3];
$group = $breakpoint ?: 'base';
if (in_array($class, $processedClasses[$group], TRUE)) {
continue;
}
if ($breakpoint) {
$config = $this->configFactory->get('utilikit.settings');
$activeBreakpoints = array_keys(array_filter($config->get('active_breakpoints') ?? []));
if (empty($activeBreakpoints)) {
$activeBreakpoints = array_keys(UtilikitConstants::DEFAULT_BREAKPOINTS);
}
if (!in_array($breakpoint, $activeBreakpoints, TRUE)) {
continue;
}
}
if (!isset($rules[$prefix])) {
continue;
}
$rule = $rules[$prefix];
if (!empty($rule['isGridTrackList'])) {
$rules['gc']['isGridTrackList'] = TRUE;
$rules['gr']['isGridTrackList'] = TRUE;
$declaration = $this->parseGridTrackList($class, $rule);
}
else {
$declaration = $this->applyRule($rule, $prefix, $suffix, $class);
}
if (!$declaration) {
continue;
}
$selector = '.' . $this->escapeCssSelector($class);
// Add !important if configured (static/head modes only)
$config = $this->configFactory->get('utilikit.settings');
$useImportant = $config->get('use_important') ?? TRUE;
$renderingMode = $config->get('rendering_mode') ?? 'inline';
if ($useImportant && in_array($renderingMode, ['static', 'head'], TRUE)) {
$declaration = str_replace(';', ' !important;', $declaration);
}
$cssRule = " $selector { $declaration }";
$cssRules[$group][] = $cssRule;
$processedClasses[$group][] = $class;
}
foreach ($cssRules as $group => &$rules) {
sort($rules);
}
$css = '';
if (!empty($cssRules['base'])) {
$css .= implode("\n", $cssRules['base']) . "\n";
}
foreach (UtilikitConstants::BREAKPOINT_VALUES as $bp => $width) {
if (!empty($cssRules[$bp])) {
$css .= "@media (min-width: {$width}px) {\n";
$css .= implode("\n", $cssRules[$bp]);
$css .= "\n}\n";
}
}
$this->cache->set($cid, $css, CacheBackendInterface::CACHE_PERMANENT, [UtilikitConstants::CACHE_TAG_CSS]);
return $css;
}
/**
* Escapes special characters in CSS selectors.
*
* Handles CSS selector escaping while preserving decimal notation with
* special handling for dots in decimal numbers (e.g., "1.5" becomes "1\\.5").
*
* @param string $selector
* The CSS selector to escape.
*
* @return string
* Escaped CSS selector safe for use in stylesheets.
*/
private function escapeCssSelector(string $selector): string {
// CSS spec requires escaping these characters in selectors
// But we need special handling for dots in decimal numbers.
// First, protect decimal dots by temporarily replacing them
// Match patterns like 0.5, 1.25, 2.5, etc.
$selector = preg_replace_callback(
'/(\d+)\.(\d+)/',
function ($matches) {
// Use a placeholder that won't be escaped.
return $matches[1] . '___DECIMAL___' . $matches[2];
},
$selector
);
// Now escape special characters (including remaining dots)
$special = ['!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',',
'.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']',
'^', '`', '{', '|', '}', '~',
];
$escaped = $selector;
foreach ($special as $char) {
$escaped = str_replace($char, '\\' . $char, $escaped);
}
// Restore decimal dots with single escaping.
$escaped = str_replace('___DECIMAL___', '\\.', $escaped);
return $escaped;
}
/**
* Applies a utility rule to generate CSS declaration.
*
* Routes rule processing to appropriate handlers based on rule type
* (sides, keywords, transforms, colors, etc.) and generates the
* corresponding CSS declaration.
*
* @param array $rule
* The utility rule definition.
* @param string $prefix
* The utility class prefix.
* @param string $suffix
* The utility class value suffix.
* @param string $className
* The complete utility class name.
*
* @return string|null
* Generated CSS declaration or NULL if rule cannot be applied.
*/
private function applyRule(array $rule, string $prefix, string $suffix, string $className): ?string {
if (!empty($rule['sides'])) {
$result = $this->applySidesRule($rule, $prefix, $suffix);
if ($result) {
return $result;
}
}
$result = $this->applySpacingShorthandRule($rule, $prefix, $suffix);
if ($result) {
return $result;
}
if (!empty($rule['isKeyword'])) {
return $this->applyKeywordRule($rule, $suffix);
}
if (!empty($rule['isTransform'])) {
return $this->applyTransformRule($rule, $suffix);
}
if ($prefix === 'gp') {
$result = $this->applyGapRule($rule, $prefix, $suffix);
if ($result) {
return $result;
}
}
if (!empty($rule['isOpacity']) || !empty($rule['isDecimalFixed']) || !empty($rule['isInteger']) || !empty($rule['isNumericFlexible'])) {
return $this->applyNumericRule($rule, $prefix, $suffix);
}
if (!empty($rule['isColor'])) {
return $this->applyColorRule($rule, $suffix);
}
if (!empty($rule['isFractional'])) {
return $this->applyFractionalRule($rule, $suffix);
}
if (!empty($rule['isRange'])) {
return $this->applyRangeRule($rule, $suffix);
}
return NULL;
}
/**
* Applies rules for properties that support directional sides.
*
* Handles utility classes that target specific sides (top, right, bottom,
* left) for properties like padding, margin, border-width, and
* border-radius.
*
* @param array $rule
* The utility rule definition.
* @param string $prefix
* The utility class prefix.
* @param string $suffix
* The utility class value suffix with side specifier.
*
* @return string|null
* Generated CSS declaration or NULL if rule doesn't match pattern.
*/
private function applySidesRule(array $rule, string $prefix, string $suffix): ?string {
if (!preg_match('/^(t|r|b|l)-(auto|\d+(?:d\d+)?(?:px|pr|em|rem|vh|vw)?)$/', $suffix, $matches)) {
return NULL;
}
$side = $matches[1];
$rawValue = $matches[2];
if ($rawValue === 'auto') {
$value = 'auto';
}
else {
// Convert 'd' to '.' for decimal notation.
$convertedValue = str_replace('d', '.', $rawValue);
// Check if value already has a unit.
if (preg_match('/^(\d+(?:\.\d+)?)(px|pr|em|rem|vh|vw)$/', $convertedValue, $unitMatches)) {
// Value already has a unit.
$unit = $unitMatches[2] === 'pr' ? '%' : $unitMatches[2];
$value = $unitMatches[1] . $unit;
}
else {
// Value has no unit, default to px.
$value = $convertedValue . 'px';
}
}
if ($prefix === 'br') {
if ($value === 'auto') {
return NULL;
}
$radiusMap = [
't' => ['top-left', 'top-right'],
'r' => ['top-right', 'bottom-right'],
'b' => ['bottom-right', 'bottom-left'],
'l' => ['top-left', 'bottom-left'],
];
$declarations = [];
foreach ($radiusMap[$side] as $corner) {
$declarations[] = "border-{$corner}-radius: $value";
}
return implode('; ', $declarations) . ';';
}
elseif ($prefix === 'bw') {
$sideMap = ['t' => 'top', 'r' => 'right', 'b' => 'bottom', 'l' => 'left'];
return "border-{$sideMap[$side]}-width: $value;";
}
else {
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
$sideMap = ['t' => '-top', 'r' => '-right', 'b' => '-bottom', 'l' => '-left'];
return "{$cssProperty}{$sideMap[$side]}: $value;";
}
}
/**
* Apply spacing shorthand rules for padding and margin (1-4 values).
*
* Examples:
* - "uk-pd--1d5rem" => "padding: 1.5rem;"
* - "uk-pd--0d5rem-1rem" => "padding: 0.5rem 1rem;"
* - "uk-mg--0d5rem-auto" => "margin: 0.5rem auto;"
*/
private function applySpacingShorthandRule(array $rule, string $prefix, string $suffix): ?string {
// Only handle rules with 'sides' property and specific prefixes.
if (empty($rule['sides']) || !in_array($prefix, ['pd', 'mg', 'bw', 'br'])) {
return NULL;
}
// Split by dash, but preserve 'auto' keyword.
$parts = explode('-', $suffix);
$values = [];
foreach ($parts as $part) {
if ($part === 'auto') {
$values[] = 'auto';
}
else {
// Updated regex to handle decimals with 'd' notation
// Matches: 10, 1d5, 0d25, 10px, 1d5rem, 0d5em, etc.
if (preg_match('/^(\d+(?:d\d+)?)(px|pr|em|rem|vh|vw)?$/', $part, $matches)) {
// Convert 'd' to '.' for decimal values.
$numericValue = str_replace('d', '.', $matches[1]);
// Properties that don't support percentage values.
$noPercentageProperties = ['bw'];
if (isset($matches[2]) && $matches[2] === 'pr' && in_array($prefix, $noPercentageProperties)) {
return NULL;
}
// Handle units.
if (!empty($matches[2])) {
$unit = $matches[2] === 'pr' ? '%' : $matches[2];
$values[] = $numericValue . $unit;
}
else {
// Default to px if no unit specified.
$values[] = $numericValue . 'px';
}
}
else {
// Invalid format.
return NULL;
}
}
}
// Validate we got 1-4 values.
if (empty($values) || count($values) > 4) {
return NULL;
}
// Apply CSS shorthand logic.
$top = $right = $bottom = $left = NULL;
switch (count($values)) {
case 1:
// All sides same.
$top = $right = $bottom = $left = $values[0];
break;
case 2:
// Vertical | Horizontal.
$top = $bottom = $values[0];
$right = $left = $values[1];
break;
case 3:
// Top | Horizontal | Bottom.
$top = $values[0];
$right = $left = $values[1];
$bottom = $values[2];
break;
case 4:
// Top | Right | Bottom | Left.
$top = $values[0];
$right = $values[1];
$bottom = $values[2];
$left = $values[3];
break;
}
// Generate CSS based on prefix.
$declarations = [];
switch ($prefix) {
case 'pd':
$declarations[] = "padding-top: $top";
$declarations[] = "padding-right: $right";
$declarations[] = "padding-bottom: $bottom";
$declarations[] = "padding-left: $left";
break;
case 'mg':
$declarations[] = "margin-top: $top";
$declarations[] = "margin-right: $right";
$declarations[] = "margin-bottom: $bottom";
$declarations[] = "margin-left: $left";
break;
case 'bw':
$declarations[] = "border-top-width: $top";
$declarations[] = "border-right-width: $right";
$declarations[] = "border-bottom-width: $bottom";
$declarations[] = "border-left-width: $left";
break;
case 'br':
$declarations[] = "border-top-left-radius: $top";
$declarations[] = "border-top-right-radius: $right";
$declarations[] = "border-bottom-right-radius: $bottom";
$declarations[] = "border-bottom-left-radius: $left";
break;
}
return implode('; ', $declarations) . ';';
}
/**
* Applies keyword-based CSS rules.
*
* Handles utility classes that use CSS keyword values like 'flex',
* 'center', 'hidden', etc., directly as the CSS property value.
*
* @param array $rule
* The utility rule definition.
* @param string $suffix
* The keyword value suffix.
*
* @return string|null
* Generated CSS declaration or NULL if invalid keyword.
*/
private function applyKeywordRule(array $rule, string $suffix): ?string {
if (!preg_match('/^([\w-]+)$/', $suffix)) {
return NULL;
}
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
return "$cssProperty: $suffix;";
}
/**
* Applies CSS transform rules (rotate, scale).
*
* Handles transform utility classes for rotation (in degrees) and
* scaling (as percentage values converted to decimal scale factors).
*
* @param array $rule
* The utility rule definition with transform type.
* @param string $suffix
* The transform value suffix.
*
* @return string|null
* Generated CSS transform declaration or NULL if invalid.
*/
private function applyTransformRule(array $rule, string $suffix): ?string {
if ($rule['isTransform'] === 'rotate') {
if (!preg_match('/^(\d+)$/', $suffix, $matches)) {
return NULL;
}
$value = (int) $matches[1];
return "transform: rotate({$value}deg);";
}
elseif ($rule['isTransform'] === 'scale') {
// Handle both percentage format (150 = 1.5x)
// and decimal format (1d5 = 1.5x)
if (str_contains($suffix, 'd')) {
// Convert 'd' format to decimal for scale values.
$suffix = str_replace('d', '.', $suffix);
if (!preg_match('/^(\d+(?:\.\d+)?)$/', $suffix, $matches)) {
return NULL;
}
$scale = (float) $matches[1];
}
else {
// Convert percentage to decimal scale factor
// (100% = 1.0) - matches JavaScript behavior.
if (!preg_match('/^(\d+)$/', $suffix, $matches)) {
return NULL;
}
$scale = (float) $matches[1] / 100;
}
return "transform: scale($scale);";
}
return NULL;
}
/**
* Applies the gap rule (single or two values) with `d`-decimal support.
*
* Handles CSS Grid and Flexbox gap properties with support for single
* values (both row and column) or separate row/column gap values.
*
* @param array $rule
* The utility rule definition.
* @param string $prefix
* The utility class prefix (should be 'gp').
* @param string $suffix
* The gap value suffix with optional row/column values.
*
* @return string|null
* Generated CSS gap declaration or NULL if invalid.
*/
private function applyGapRule(array $rule, string $prefix, string $suffix): ?string {
if ($prefix !== 'gp') {
return NULL;
}
// Accept integers or d-decimals, optional second value, and optional 'p'
// for percent.
if (!preg_match('/^(\d+(?:d\d+)?)(?:-(\d+(?:d\d+)?))?(p?)$/', $suffix, $matches)) {
return NULL;
}
$row = $this->convertDecimalNotation($matches[1]);
$col = !empty($matches[2]) ? $this->convertDecimalNotation($matches[2]) : $row;
$unit = ($matches[3] === 'p') ? '%' : 'px';
$rowGap = $row . $unit;
$colGap = $col . $unit;
return "gap: {$rowGap} {$colGap};";
}
/**
* Applies numeric rules (opacity, integers, decimals, flexible units).
*
* Handles various numeric value types including opacity (0-100),
* integers, decimal fixed values, and flexible numeric values with
* unit support.
*
* @param array $rule
* The utility rule definition with numeric type flags.
* @param string $prefix
* The utility class prefix for context.
* @param string $suffix
* The numeric value suffix.
*
* @return string|null
* Generated CSS declaration or NULL if invalid numeric value.
*/
private function applyNumericRule(array $rule, string $prefix, string $suffix): ?string {
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
// Handle opacity first.
if (!empty($rule['isOpacity'])) {
if (!preg_match('/^(\d{1,3})$/', $suffix, $matches)) {
return NULL;
}
$value = (int) $matches[1];
if ($value < 0 || $value > 100) {
return NULL;
}
$opacity = $value / 100;
return "$cssProperty: $opacity;";
}
// Handle decimal fixed with 'd' notation.
if (!empty($rule['isDecimalFixed'])) {
if (!preg_match('/^(?:\d+|\d+d\d{1,2}|0d\d{1,2})$/', $suffix)) {
return NULL;
}
$value = str_replace('d', '.', $suffix);
return "$cssProperty: $value;";
}
// Handle integer.
if (!empty($rule['isInteger'])) {
if (!preg_match('/^(-?\d+)$/', $suffix, $matches)) {
return NULL;
}
return "$cssProperty: {$matches[1]};";
}
// Handle numeric flexible with 'd' notation.
if (!empty($rule['isNumericFlexible'])) {
if (!empty($rule['allowAuto']) && $suffix === 'auto') {
return "$cssProperty: auto;";
}
if (!preg_match('/^(\d+(?:d\d+)?)(px|pr|em|rem|vh|vw)?$/', $suffix, $matches)) {
return NULL;
}
// Convert 'd' to '.'.
$value = str_replace('d', '.', $matches[1]);
// Properties that don't support percentage values.
$noPercentageProperties = ['bw', 'ls'];
if (isset($matches[2]) && $matches[2] === 'pr' && in_array($prefix, $noPercentageProperties)) {
return NULL;
}
$viewportAllowedRules = ['wd', 'ht', 'xw', 'xh', 'nw', 'nh', 'fs'];
if (in_array($matches[2] ?? '', ['vh', 'vw']) && !in_array($prefix, $viewportAllowedRules)) {
return NULL;
}
// Special handling for line-height without units.
if ($prefix === 'lh' && empty($matches[2]) && floatval($value) <= 10) {
// Unitless.
return "$cssProperty: $value;";
}
$unit = 'px';
if (!empty($matches[2])) {
$unit = $matches[2] === 'pr' ? '%' : $matches[2];
}
return "$cssProperty: {$value}{$unit};";
}
return NULL;
}
/**
* Applies color rules with hex values and optional alpha transparency.
*
* Handles color utility classes supporting 3-digit and 6-digit hex
* values with optional alpha transparency specified as percentage.
*
* @param array $rule
* The utility rule definition for color properties.
* @param string $suffix
* The color value suffix (hex with optional alpha).
*
* @return string|null
* Generated CSS color declaration or NULL if invalid color.
*/
private function applyColorRule(array $rule, string $suffix): ?string {
$color = $this->parseColorWithAlpha($suffix);
if ($color === NULL) {
return NULL;
}
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
return "$cssProperty: $color;";
}
/**
* Convert 'd' decimal notation to standard decimal notation.
*
* @param string $value
* Value with 'd' as decimal separator.
*
* @return string
* Value with '.' as decimal separator.
*/
protected function convertDecimalNotation(string $value): string {
return str_replace('d', '.', $value);
}
/**
* Applies fractional rules for CSS Grid fr units.
*
* Handles utility classes that use CSS Grid fractional units (fr)
* for flexible track sizing.
*
* @param array $rule
* The utility rule definition.
* @param string $suffix
* The fractional value suffix.
*
* @return string|null
* Generated CSS declaration with fr unit or NULL if invalid.
*/
private function applyFractionalRule(array $rule, string $suffix): ?string {
if (!preg_match('/^(\d+)fr$/', $suffix, $matches)) {
return NULL;
}
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
return "$cssProperty: {$matches[1]}fr;";
}
/**
* Applies range rules for CSS Grid line positioning.
*
* Handles utility classes that specify grid line ranges using
* start/end syntax (e.g., "1-3" becomes "1 / 3").
*
* @param array $rule
* The utility rule definition.
* @param string $suffix
* The range value suffix (start-end format).
*
* @return string|null
* Generated CSS grid positioning declaration or NULL if invalid.
*/
private function applyRangeRule(array $rule, string $suffix): ?string {
if (!preg_match('/^(\d+)-(\d+)$/', $suffix, $matches)) {
return NULL;
}
$cssProperty = UtilikitRules::getCssPropertyName($rule['css']);
return "$cssProperty: {$matches[1]} / {$matches[2]};";
}
/**
* Parses color values with optional alpha transparency.
*
* Converts hex color values (3 or 6 digits) with optional alpha
* percentage into CSS color values (hex or rgba format).
*
* @param string $suffix
* Color suffix with optional alpha (e.g., "ff0000-50").
*
* @return string|null
* CSS color value or NULL if invalid format.
*/
private function parseColorWithAlpha(string $suffix): ?string {
if (preg_match('/^([0-9a-fA-F]{6})(?:-(\d{1,3}))?$/', $suffix, $matches)) {
$hex = $matches[1];
$alpha = isset($matches[2]) ? min((int) $matches[2], 100) / 100 : 1;
if ($alpha < 1) {
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return "rgba($r, $g, $b, $alpha)";
}
return "#$hex";
}
if (preg_match('/^([0-9a-fA-F]{3})(?:-(\d{1,3}))?$/', $suffix, $matches)) {
$hex = str_split($matches[1]);
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
$alpha = isset($matches[2]) ? min((int) $matches[2], 100) / 100 : 1;
if ($alpha < 1) {
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return "rgba($r, $g, $b, $alpha)";
}
return "#$hex";
}
return NULL;
}
/**
* Parse grid template values with decimal support.
*
* Examples:
* - "uk-gc--1fr-2d5fr-0d5fr" => "grid-template-columns: 1fr 2.5fr 0.5fr;"
* - "uk-gc--repeat-3-1d5fr" => "grid-template-columns: repeat(3, 1.5fr);"
*/
private function parseGridTrackList(string $class, array $rule): ?string {
// Extract the suffix after prefix--.
if (!preg_match('/^uk-(?:(?:sm|md|lg|xl|xxl)-)?(?:gc|gr)--(.+)$/', $class, $matches)) {
return NULL;
}
$suffix = $matches[1];
$isColumns = str_contains($class, '-gc--');
// Validate against malformed patterns - reject nested repeat calls.
if (preg_match('/repeat.*repeat/', $suffix)) {
return NULL;
}
// Use smart split to handle complex cases.
$tokens = $this->smartSplitGrid($suffix);
$parsed = [];
$i = 0;
while ($i < count($tokens)) {
$token = $tokens[$i];
// Convert 'd' to '.' in the token BEFORE processing.
$token = str_replace('d', '.', $token);
// Additional validation - reject if we encounter 'repeat'
// as a regular token
// (not as the start of a repeat function)
if ($token === 'repeat' && $i > 0 && $tokens[$i - 1] !== 'repeat') {
return NULL;
}
// Handle different token types.
if ($token === 'repeat') {
// Handle repeat() function.
if ($i + 1 < count($tokens)) {
$count = str_replace('d', '.', $tokens[$i + 1]);
// Check if next token is minmax for auto-fit/auto-fill patterns.
if (($count === 'auto-fit' || $count === 'auto-fill' || $count === 'autofit' || $count === 'autofill')
&& $i + 2 < count($tokens) && $tokens[$i + 2] === 'minmax'
&& $i + 4 < count($tokens)) {
// Handle repeat(auto-fit, minmax(...))
$minVal = str_replace('d', '.', $tokens[$i + 3]);
$maxVal = str_replace('d', '.', $tokens[$i + 4]);
// Validate minmax parameters - fitc cannot be used in minmax.
if ($maxVal === 'fitc' || $minVal === 'fitc') {
return NULL;
}
// Convert units.
$minVal = $this->convertGridUnit($minVal);
$maxVal = $this->convertGridUnit($maxVal);
// Normalize auto-fit/auto-fill.
$countNormalized = str_replace('autofit', 'auto-fit', $count);
$countNormalized = str_replace('autofill', 'auto-fill', $countNormalized);
$parsed[] = "repeat($countNormalized, minmax($minVal, $maxVal))";
$i += 5;
continue;
}
// Check if next token is minmax for ANY repeat pattern.
elseif ($i + 2 < count($tokens) && $tokens[$i + 2] === 'minmax'
&& $i + 4 < count($tokens)) {
// Handle repeat(n, minmax(...))
$minVal = str_replace('d', '.', $tokens[$i + 3]);
$maxVal = str_replace('d', '.', $tokens[$i + 4]);
// Validate minmax parameters - fitc cannot be used in minmax.
if ($maxVal === 'fitc' || $minVal === 'fitc') {
return NULL;
}
// Convert units.
$minVal = $this->convertGridUnit($minVal);
$maxVal = $this->convertGridUnit($maxVal);
$parsed[] = "repeat($count, minmax($minVal, $maxVal))";
$i += 5;
continue;
}
// Regular repeat for non-minmax patterns.
elseif ($i + 2 < count($tokens)) {
$value = str_replace('d', '.', $tokens[$i + 2]);
$value = $this->convertGridUnit($value);
$countNormalized = str_replace('autofit', 'auto-fit', $count);
$countNormalized = str_replace('autofill', 'auto-fill', $countNormalized);
$parsed[] = "repeat($countNormalized, $value)";
$i += 3;
continue;
}
}
}
elseif ($token === 'minmax') {
// Handle minmax() function.
if ($i + 2 < count($tokens)) {
$min = str_replace('d', '.', $tokens[$i + 1]);
$max = str_replace('d', '.', $tokens[$i + 2]);
// Validate minmax parameters - fitc cannot be used in minmax.
if ($max === 'fitc' || $min === 'fitc') {
return NULL;
}
$min = $this->convertGridUnit($min);
$max = $this->convertGridUnit($max);
$parsed[] = "minmax($min, $max)";
$i += 3;
continue;
}
}
elseif ($token === 'fitc' || $token === 'fit-content') {
// Handle fit-content() function.
if ($i + 1 < count($tokens)) {
$size = str_replace('d', '.', $tokens[$i + 1]);
$size = $this->convertGridUnit($size);
$parsed[] = "fit-content($size)";
$i += 2;
continue;
}
else {
// fit-content without parameters.
$parsed[] = "fit-content()";
$i++;
continue;
}
}
else {
// Regular value (auto, fr, px, %, etc.)
$value = $this->convertGridUnit($token);
$parsed[] = $value;
$i++;
}
}
$cssValue = implode(' ', $parsed);
$property = $isColumns ? 'grid-template-columns' : 'grid-template-rows';
return "$property: $cssValue;";
}
/**
* Convert grid unit values, handling 'd' notation.
*
* Processes grid-specific units and keyword values, converting
* abbreviated forms to full CSS values.
*
* @param string $value
* Grid unit value to convert.
*
* @return string
* Converted CSS grid value.
*/
private function convertGridUnit(string $value): string {
// Handle 'pr' to '%' conversion.
$value = str_replace('pr', '%', $value);
// Handle minc/maxc keywords.
$value = str_replace('minc', 'min-content', $value);
$value = str_replace('maxc', 'max-content', $value);
// Handle pure numbers without units - add px.
if (preg_match('/^\d+(\.\d+)?$/', $value)) {
return $value . 'px';
}
// Return as-is for: auto, fr units, px, %, em, rem, etc.
return $value;
}
/**
* Smart split for grid values, preserving decimal notation.
*
* Examples:
* - "1fr-2d5fr-0d5fr" => ["1fr", "2d5fr", "0d5fr"]
* - "repeat-3-1d5fr" => ["repeat", "3", "1d5fr"]
* - "minmax-0d5rem-2fr" => ["minmax", "0d5rem", "2fr"]
*/
private function smartSplitGrid(string $str): array {
$tokens = [];
$current = '';
$length = strlen($str);
for ($i = 0; $i < $length; $i++) {
$char = $str[$i];
if ($char === '-') {
// Check if this dash is part of auto-fit or auto-fill.
if ($current === 'auto' && $i + 1 < $length) {
$remaining = substr($str, $i + 1);
if (str_starts_with($remaining, 'fit')) {
$current = 'auto-fit';
// Skip 'fit'.
$i += 3;
$tokens[] = $current;
$current = '';
continue;
}
elseif (str_starts_with($remaining, 'fill')) {
$current = 'auto-fill';
// Skip 'fill'.
$i += 4;
$tokens[] = $current;
$current = '';
continue;
}
}
// Check if this dash is part of min-content or max-content.
if ($current === 'min' && str_starts_with(substr($str, $i + 1), 'content')) {
$current = 'min-content';
// Skip 'content'.
$i += 7;
$tokens[] = $current;
$current = '';
continue;
}
if ($current === 'max' && str_starts_with(substr($str, $i + 1), 'content')) {
$current = 'max-content';
// Skip 'content'.
$i += 7;
$tokens[] = $current;
$current = '';
continue;
}
// Check if this dash is part of fit-content.
if ($current === 'fit' && str_starts_with(substr($str, $i + 1), 'content')) {
$current = 'fit-content';
// Skip 'content'.
$i += 7;
$tokens[] = $current;
$current = '';
continue;
}
// Otherwise, this dash is a separator.
if ($current !== '') {
$tokens[] = $current;
$current = '';
}
}
else {
$current .= $char;
}
}
// Don't forget the last token.
if ($current !== '') {
$tokens[] = $current;
}
return $tokens;
}
/**
* Add this to a Drush command or test class.
*/
public function testGridParser(): void {
$tests = [
// ==========================================
// LEVEL 1: BASIC VALUES
// ==========================================
'uk-gc--auto-auto' => 'grid-template-columns: auto auto;',
'uk-gc--200px-1fr' => 'grid-template-columns: 200px 1fr;',
'uk-gc--1fr-auto-2fr' => 'grid-template-columns: 1fr auto 2fr;',
'uk-gc--100px-1fr-100px' => 'grid-template-columns: 100px 1fr 100px;',
'uk-gc--1fr-2fr-0.5fr' => 'grid-template-columns: 1fr 2fr 0.5fr;',
'uk-gr--auto-1fr-auto' => 'grid-template-rows: auto 1fr auto;',
// ==========================================
// LEVEL 2: SIMPLE FUNCTIONS
// ==========================================
// 2A: Simple repeat()
'uk-gc--repeat-3-1fr' => 'grid-template-columns: repeat(3, 1fr);',
'uk-gc--repeat-2-200px' => 'grid-template-columns: repeat(2, 200px);',
'uk-gc--repeat-4-1fr' => 'grid-template-columns: repeat(4, 1fr);',
// 2B: Simple minmax()
'uk-gc--minmax-100px-1fr' => 'grid-template-columns: minmax(100px, 1fr);',
'uk-gc--minmax-200px-1fr' => 'grid-template-columns: minmax(200px, 1fr);',
'uk-gc--minmax-50pr-2fr' => 'grid-template-columns: minmax(50%, 2fr);',
'uk-gc--minmax-10rem-1fr' => 'grid-template-columns: minmax(10rem, 1fr);',
// 2C: Simple fit-content()
'uk-gc--fitc-200px' => 'grid-template-columns: fit-content(200px);',
'uk-gc--fitc-50pr' => 'grid-template-columns: fit-content(50%);',
// ==========================================
// LEVEL 3: NESTED FUNCTIONS
// ==========================================
'uk-gc--repeat-3-minmax-200px-1fr' => 'grid-template-columns: repeat(3, minmax(200px, 1fr));',
'uk-gc--repeat-2-minmax-100px-1fr' => 'grid-template-columns: repeat(2, minmax(100px, 1fr));',
// ==========================================
// LEVEL 4: AUTO-RESPONSIVE PATTERNS
// ==========================================
// 4A: auto-fit with minmax
'uk-gc--repeat-auto-fit-minmax-280px-1fr' => 'grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));',
'uk-gc--repeat-auto-fit-minmax-200px-1fr' => 'grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));',
'uk-gc--repeat-auto-fit-minmax-250px-1fr' => 'grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));',
'uk-gc--repeat-auto-fit-minmax-300px-1fr' => 'grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));',
// 4B: auto-fill with minmax
'uk-gc--repeat-auto-fill-minmax-250px-1fr' => 'grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));',
'uk-gc--repeat-auto-fill-minmax-200px-1fr' => 'grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));',
'uk-gc--repeat-auto-fill-minmax-150px-1fr' => 'grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));',
// 4C: auto-fit/fill with fixed sizes
'uk-gc--repeat-auto-fit-250px' => 'grid-template-columns: repeat(auto-fit, 250px);',
'uk-gc--repeat-auto-fill-200px' => 'grid-template-columns: repeat(auto-fill, 200px);',
// ==========================================
// LEVEL 5: MIXED PATTERNS
// ==========================================
'uk-gc--fitc-300px-1fr' => 'grid-template-columns: fit-content(300px) 1fr;',
'uk-gc--1fr-fitc-250px' => 'grid-template-columns: 1fr fit-content(250px);',
// ==========================================
// EXISTING COMPLEX PATTERNS (Keep)
// ==========================================
// Spacing shorthand with decimals
'uk-pd--1d5rem' => 'padding-top: 1.5rem; padding-right: 1.5rem; padding-bottom: 1.5rem; padding-left: 1.5rem;',
'uk-pd--0d5rem-1rem' => 'padding-top: 0.5rem; padding-right: 1rem; padding-bottom: 0.5rem; padding-left: 1rem;',
'uk-mg--0d5rem-auto' => 'margin-top: 0.5rem; margin-right: auto; margin-bottom: 0.5rem; margin-left: auto;',
'uk-pd--1d25rem-2d5rem-0d75rem-1rem' => 'padding-top: 1.25rem; padding-right: 2.5rem; padding-bottom: 0.75rem; padding-left: 1rem;',
// Grid templates with decimals.
'uk-gc--1fr-2d5fr-0d5fr' => 'grid-template-columns: 1fr 2.5fr 0.5fr;',
'uk-gc--repeat-3-1d5fr' => 'grid-template-columns: repeat(3, 1.5fr);',
'uk-gc--minmax-0d5rem-2fr' => 'grid-template-columns: minmax(0.5rem, 2fr);',
'uk-gc--repeat-auto-fit-minmax-12d5rem-1fr' => 'grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));',
'uk-gr--0d75fr-1fr-0d25fr' => 'grid-template-rows: 0.75fr 1fr 0.25fr;',
// Complex grid with decimals (has typo - extra parenthesis)
'uk-gc--100px-1d5fr-minmax-7d5rem-1fr)' => 'grid-template-columns: 100px 1.5fr minmax(7.5rem, 1fr);',
];
// Run tests with complexity logging.
echo " Testing PHP Grid Parser (Organized by Complexity):\n";
echo " Test Distribution:\n";
echo " Level 1 (Basic Values): 5 tests\n";
echo " Level 2 (Simple Functions): 7 tests\n";
echo " Level 3 (Nested Functions): 2 tests\n";
echo " Level 4 (Auto-Responsive): 9 tests\n";
echo " Level 5 (Mixed + Decimals): 11 tests\n";
echo " Total: 34 comprehensive test cases\n\n";
$passed = 0;
$failed = 0;
$testCount = 0;
foreach ($tests as $input => $expected) {
$rule = ['css' => 'gridTemplateColumns', 'isGridTrackList' => TRUE];
$result = $this->parseGridTrackList($input, $rule);
$success = $result === $expected;
if ($success) {
$passed++;
}
else {
$failed++;
}
// Determine complexity level for logging.
if ($testCount < 5) {
$level = '1';
}
elseif ($testCount < 12) {
$level = '2';
}
elseif ($testCount < 14) {
$level = '3';
}
elseif ($testCount < 23) {
$level = '4';
}
else {
$level = '5';
}
$this->logger->info(sprintf(
'%s [L%s] %s | Expected: %s | Got: %s',
$success ? '✅' : '❌',
$level,
$input,
$expected,
$result ?: 'null'
));
$testCount++;
}
$successRate = round(($passed / count($tests)) * 100);
$this->logger->info("Results: $passed passed, $failed failed ({$successRate}% success rate)");
if ($failed > 0) {
$this->logger->warning('Some tests failed - check parser logic for failed patterns');
}
else {
$this->logger->info('All grid parser tests passed!');
}
}
}
