*/ public const INTERACTION = ['md-state-layer', 'md-focus-ring', 'md-touch-target']; /** @var list|null */ private static ?array $text = null; /** * Every class a view may write. * * @return list */ public static function allowed(): array { return [...self::INTERACTION, ...self::text()]; } /** * The text classes, read from the rules text.css declares, so a class added there is allowed * here without a second list. * * @return list */ public static function text(): array { if (self::$text === null) { preg_match_all('/^\s*\.(md-[a-z0-9-]+)\s*\{/m', (string) file_get_contents(dirname(__DIR__, 2).'/resources/css/text.css'), $matches); self::$text = array_values(array_unique($matches[1])); } return self::$text; } /** * What the Blade source writes as a class beyond the allowed ones, one line per offence; empty * when the view keeps to the rule. * * @return list */ public static function violations(string $view): array { // A Blade comment documents a caller's usage (`icon-class="text-sport-run"`); it renders nothing. $view = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $view); $allowed = self::allowed(); $violations = []; $check = function (string $where, string $list) use ($allowed, &$violations): void { $extra = array_diff(preg_split('/\s+/', trim($list), -1, PREG_SPLIT_NO_EMPTY), $allowed); if ($extra !== []) { $violations[] = "{$where} writes ".implode(' ', $extra); } }; // `@class()`, `Arr::toCssClasses` maps and Alpine's `x-bind:class` are never how a class // reaches the page. if (preg_match_all('/@class\(|toCssClasses|x-bind:class=/', $view, $matches) > 0) { $violations[] = 'uses '.implode(', ', array_unique($matches[0])); } // A literal `class="…"` (or a nested component's `hint-class`, `icon-class`, `box-class`): // allowed classes, with a simple `@if (…) … @endif` around one of them, or a caller's value // forwarded whole (`class="{{ $hintClass }}"`). preg_match_all('/(?get('class')") { $violations[] = ":class=\"{$content}\" is not the caller's class forwarded whole"; } } // A PHP `'class' => …` entry (an attribute bag built by hand) holds a plain string literal // of allowed classes, never an expression that could add others. preg_match_all('/([\'"])class\1\s*=>\s*(?:([\'"])([^\'"]*)\2(?=\s*[,\])])|([^,\]\n]*))/', $view, $entries, PREG_SET_ORDER); foreach ($entries as $entry) { if (($entry[4] ?? '') !== '' || ! isset($entry[3])) { $violations[] = "'class' => ".trim($entry[4] ?? '').' is not a string literal'; continue; } $check("'class' => '{$entry[3]}'", $entry[3]); } // `->class('…')` and the items of `->class([…])`: only the array's own keys and values at // its top level — a condition may nest brackets of its own (`in_array($size, ['xs'])`). preg_match_all('/->class\(\s*([\'"])([^\'"]*)\1\s*\)/', $view, $strings, PREG_SET_ORDER); foreach ($strings as [, , $list]) { $check("->class('{$list}')", $list); } preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $calls, PREG_SET_ORDER); foreach ($calls as [, $arguments]) { $depth = 0; for ($i = 0, $length = strlen($arguments); $i < $length; $i++) { $char = $arguments[$i]; if ($char === '[' || $char === '(') { $depth++; } elseif ($char === ']' || $char === ')') { $depth--; } elseif ($depth === 0 && $char === "'") { $end = (int) strpos($arguments, "'", $i + 1); $check("->class(['".substr($arguments, $i + 1, $end - $i - 1)."'])", substr($arguments, $i + 1, $end - $i - 1)); $i = $end; } } } return $violations; } }