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([]);
});