Stylesheets::resetCache()); /** * A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP * and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class * list (`bg-primary`, `medium:hidden`, `md-ink-variant`) as a `class` attribute on its own line. * Prose, props, attributes and custom properties are left out. * * @return array{view: string, css: string} */ function boostTextAsCode(string $text): array { $view = []; $css = []; $fence = null; $skipping = false; foreach (explode("\n", $text) as $line) { $viewLine = ''; $cssLine = ''; if (preg_match('/^```\s*([a-z]*)/', $line, $open) === 1) { $fence = $fence === null ? ($open[1] ?: 'text') : null; } elseif ($fence !== null) { if (! $skipping && $fence === 'css') { $cssLine = $line; } elseif (! $skipping && in_array($fence, ['blade', 'html', 'php', 'js'], true)) { $viewLine = $line; } } else { if (preg_match('/^## (.+)$/', $line, $heading) === 1) { $skipping = in_array($heading[1], BOOST_SECTIONS_NAMING_WHAT_FAILS, true); } if (! $skipping) { preg_match_all('/`([^`]+)`/', $line, $spans); $viewLine = collect($spans[1]) ->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([]); });