map(fn (string $span): ?string => match (true) { str_contains($span, '<') => $span, boostSpanIsClassList($span) => '
', default => null, }) ->filter() ->implode(' '); } } $view[] = $viewLine; $css[] = $cssLine; } return ['view' => implode("\n", $view), 'css' => implode("\n", $css)]; } /** * The hyphenated names the texts also write alone in a span for something that is not a class: * every property the package's stylesheets declare (`border-color`), every component prop * (`placeholder-value`) and every colour role (`outline-variant`). Each is shaped like a colour * utility on a name the guard does not know, which is what an application's own theme colour * (`bg-brand`) looks like. * * @return array */ function boostNamesThatAreNotClasses(): array { static $names = null; if ($names !== null) { return $names; } $names = []; $root = __DIR__.'/../../resources'; foreach (File::allFiles("{$root}/css") as $file) { preg_match_all('/(?getContents(), $matches); foreach ([...$matches[1], ...$matches[2]] as $name) { $names[$name] = true; } } foreach (File::files("{$root}/views/components") as $file) { if (preg_match('/@props\(\[(.*?)\]\)/s', $file->getContents(), $props) === 1) { preg_match_all("/^\s*'(\w+)'/m", $props[1], $keys); foreach ($keys[1] as $key) { $names[Str::kebab($key)] = true; } } } return $names; } /** * Whether an inline span reads as a class list. Not: a CSS declaration (`font: var(…)`), a * command (`php artisan …`), a single bare word, which is as often a role, prop or value * (`outline`, `link`, `hidden`) as a utility, or a single property, prop or role name * (`border-color`, `placeholder-value`, `outline-variant`). */ function boostSpanIsClassList(string $span): bool { $tokens = preg_split('/\s+/', trim($span)); if (count($tokens) === 1 && (preg_match('/[-:]/', $tokens[0]) !== 1 || isset(boostNamesThatAreNotClasses()[$tokens[0]]))) { return false; } if (preg_match('/^(?:php|npm|npx|composer|git)\b|\b(?:var|calc|color-mix)\(|^[a-z-]+:\s/', $span) === 1) { return false; } foreach ($tokens as $token) { if (preg_match('/^(?:--|data-|aria-|wire:|x-|@|\$)/', $token) === 1 || str_contains($token, '::')) { return false; } if (preg_match('/^[a-z0-9][a-z0-9:\/.\[\]()#%_-]*$/', $token) !== 1) { return false; } } return true; } it('teaches only classes and variants the stylesheets define', function () { $directory = sys_get_temp_dir().'/livewire-material-boost-'.bin2hex(random_bytes(4)); $files = []; foreach (BOOST_TEXTS as $index => $path) { $code = boostTextAsCode(File::get(__DIR__.'/../../'.$path)); File::ensureDirectoryExists($directory); File::put($files["{$directory}/text-{$index}.blade.php"] = "{$directory}/text-{$index}.blade.php", $code['view']); File::put($files["{$directory}/text-{$index}.css"] = "{$directory}/text-{$index}.css", $code['css']); } try { $violations = DesignGuard::scan(realpath($directory))->violations(); } finally { File::deleteDirectory($directory); } $named = array_map(function (string $violation) use ($directory): string { return (string) preg_replace_callback( '#^'.preg_quote(realpath(dirname($directory)) ?: dirname($directory), '#').'/[^/]+/text-(\d+)\.(?:blade\.php|css)#', fn (array $match): string => BOOST_TEXTS[(int) $match[1]], $violation, ); }, $violations); expect($named)->toBe([]); });