107 lines
3.6 KiB
PHP
107 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace Modules\Stylist\Console;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Filesystem\Filesystem;
|
|
use Modules\Stylist\Services\ThemeValidator;
|
|
use Modules\Stylist\Theme\Loader;
|
|
use RuntimeException;
|
|
use ZipArchive;
|
|
|
|
final class ValidateThemeCommand extends Command
|
|
{
|
|
protected $signature = 'stylist:validate {theme : Installed theme key, theme directory, or theme ZIP path}';
|
|
|
|
protected $description = 'Validate a Stylist theme manifest, files, references, and accessibility.';
|
|
|
|
public function __construct(
|
|
private readonly Filesystem $files,
|
|
private readonly Loader $loader,
|
|
private readonly ThemeValidator $validator,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$temporary = null;
|
|
|
|
try {
|
|
[$path, $temporary] = $this->resolveThemePath((string) $this->argument('theme'));
|
|
$theme = $this->loader->fromPath($path);
|
|
$issues = $this->validator->validate($theme);
|
|
|
|
$this->info("Theme: {$theme->name()} ({$theme->key()})");
|
|
$this->line('Manifest schema: '.$theme->manifestVersion());
|
|
|
|
if ($issues === []) {
|
|
$this->info('Theme validation passed with no issues.');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->table(
|
|
['Severity', 'File', 'Message'],
|
|
array_map(fn (array $issue) => [
|
|
strtoupper($issue['severity']),
|
|
$issue['file'],
|
|
$issue['message'],
|
|
], $issues),
|
|
);
|
|
|
|
$errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error'));
|
|
$warnings = count($issues) - $errors;
|
|
$this->line("{$errors} error(s), {$warnings} warning(s).");
|
|
|
|
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
|
} catch (\Throwable $exception) {
|
|
$this->error($exception->getMessage());
|
|
return self::FAILURE;
|
|
} finally {
|
|
if ($temporary !== null) {
|
|
$this->files->deleteDirectory($temporary);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return array{0:string,1:?string} */
|
|
private function resolveThemePath(string $input): array
|
|
{
|
|
if ($this->files->isDirectory($input)) {
|
|
return [realpath($input) ?: $input, null];
|
|
}
|
|
|
|
if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') {
|
|
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
|
|
$this->files->ensureDirectoryExists($temporary);
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($input) !== true) {
|
|
throw new RuntimeException('The theme ZIP could not be opened.');
|
|
}
|
|
|
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
|
$name = (string) $zip->getNameIndex($index);
|
|
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
|
|
$zip->close();
|
|
throw new RuntimeException('The ZIP contains an unsafe path.');
|
|
}
|
|
}
|
|
|
|
$zip->extractTo($temporary);
|
|
$zip->close();
|
|
|
|
$manifest = collect($this->files->allFiles($temporary))
|
|
->first(fn ($file) => $file->getFilename() === 'theme.json');
|
|
|
|
if (! $manifest) {
|
|
throw new RuntimeException('The package does not contain theme.json.');
|
|
}
|
|
|
|
return [$manifest->getPath(), $temporary];
|
|
}
|
|
|
|
return [$this->loader->find($input)->path(), null];
|
|
}
|
|
}
|