*/ protected const SPECS = ['2021', '2025']; /** * M3's other two contrast levels, generated for every scheme beside the standard one and * keyed on (styles/color/roles, "What's new May 2025"): medium is * 3:1 on the roles that carry text, high 7:1. Their levels are Google's, not the * installation's — `--contrast` moves the standard block alone. * * @var array */ protected const LEVELS = ['medium' => 0.5, 'high' => 1.0]; public function handle(Filesystem $files): int { $stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css'); $data = preg_replace('/\.css$/', '', $stylesheet).'.json'; $spec = (string) $this->option('spec'); if (! in_array($spec, self::SPECS, true)) { $this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.'); return self::FAILURE; } if (filled($this->argument('seed'))) { if ($this->refusesContrast((float) $this->option('contrast'))) { return self::FAILURE; } $input = [ 'seed' => (string) $this->argument('seed'), 'variant' => (string) $this->option('variant'), 'spec' => $spec, 'contrast' => (float) $this->option('contrast'), 'harmonize' => (bool) $this->option('harmonize'), 'success' => (string) $this->option('success'), 'warning' => (string) $this->option('warning'), 'info' => (string) $this->option('info'), ]; $scheme = $this->generate($input); if ($scheme === null) { return self::FAILURE; } return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme); } $profiles = config('livewire-material.profiles'); if (! is_array($profiles) || $profiles === []) { $this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.'); return self::FAILURE; } $generated = []; foreach ($profiles as $name => $profile) { if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) { $this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not."); return self::FAILURE; } if ($this->refusesContrast((float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ")) { return self::FAILURE; } // A profile's own spec and state colours win; without them, the command's options apply. $scheme = $this->generate([ 'seed' => (string) ($profile['seed'] ?? ''), 'variant' => (string) ($profile['variant'] ?? 'tonal-spot'), 'spec' => (string) ($profile['spec'] ?? $spec), 'contrast' => (float) ($profile['contrast'] ?? 0), 'harmonize' => (bool) ($profile['harmonize'] ?? $this->option('harmonize')), 'success' => (string) ($profile['success'] ?? $this->option('success')), 'warning' => (string) ($profile['warning'] ?? $this->option('warning')), 'info' => (string) ($profile['info'] ?? $this->option('info')), ], "Profile \"{$name}\": "); if ($scheme === null) { return self::FAILURE; } $generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme]; } $configured = config('livewire-material.profile'); $default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated); return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [ ...collect($generated[$default])->except('label')->all(), 'default' => $default, 'profiles' => $generated, ]); } /** * Whether the given standard contrast level is one this command generates, having said why * it is not. Medium and high are M3's own levels and always generated; `--contrast` moves * the standard block, which has to stay below them. */ protected function refusesContrast(float $contrast, string $context = ''): bool { if ($contrast >= self::LEVELS['medium'] || $contrast < -1) { $this->components->error($context.sprintf( 'The contrast level %s is the standard block\'s, from -1 to below %s. Medium (%s) and high (%s) are always generated beside it, under [data-contrast]; the head script picks one.', $contrast, self::LEVELS['medium'], self::LEVELS['medium'], self::LEVELS['high'], )); return true; } return false; } /** * One scheme from Google's colour utilities, or null once the reason has been shown. * * @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input * @return array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array{standard: float, medium: array{light: array, dark: array}, high: array{light: array, dark: array}}, light: array, dark: array}|null */ protected function generate(array $input, string $context = ''): ?array { $result = Process::run([ config('livewire-material.node', 'node'), __DIR__.'/../../resources/node/scheme.mjs', json_encode($input), ]); if ($result->failed()) { $this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?')); return null; } try { return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { $this->components->error($context.'The scheme generator answered with something other than JSON.'); return null; } } /** * @param array $scheme */ protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int { $files->ensureDirectoryExists(dirname($stylesheet)); $files->put($stylesheet, $css); $files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); $this->components->info("Wrote {$stylesheet} and {$data}."); return self::SUCCESS; } /** * The stylesheet, headed by the command that regenerates it: every option that differs from * its default is written out. * * @param array{seed: string, variant: string, spec: string, harmonize: bool, contrast: array, light: array, dark: array} $scheme * @param array{seed: string, variant: string, spec: string, contrast: float, harmonize: bool, success: string, warning: string, info: string} $input */ protected function stylesheet(array $scheme, array $input): string { $states = collect(['success', 'warning', 'info']) ->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault())) ->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state]))) ->implode(''); $command = sprintf( 'php artisan material:scheme "%s" --variant=%s%s%s%s%s', $scheme['seed'], $scheme['variant'], $input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '', $scheme['contrast']['standard'] != 0 ? ' --contrast='.$scheme['contrast']['standard'] : '', $scheme['harmonize'] ? ' --harmonize' : '', $states, ); $blocks = SchemeStylesheet::levels($scheme); return <<, light: array, dark: array}> $profiles */ protected function profilesStylesheet(array $profiles, string $default): string { $list = collect($profiles) ->map(fn (array $profile, string $name): string => sprintf( ' * %-12s %s, %s%s%s%s', $name, $profile['seed'], $profile['variant'], $profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '', $profile['contrast']['standard'] != 0 ? ', contrast '.$profile['contrast']['standard'] : '', $profile['harmonize'] ? ', harmonized' : '', )) ->implode("\n"); $css = << $profile) { $css .= "\n".SchemeStylesheet::levels($profile, "[data-scheme='{$name}']"); } return $css; } }