New Branch Structure
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
final class CreateThemeCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:create-theme {key : Lowercase theme key} {--parent=common : Parent theme key} {--name= : Display name}';
|
||||
protected $description = 'Create a minimal child theme without copying shared views.';
|
||||
|
||||
public function __construct(private readonly Filesystem $files)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$key = strtolower(trim((string) $this->argument('key')));
|
||||
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
|
||||
$this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
|
||||
if ($this->files->exists($path)) {
|
||||
$this->error("Theme [{$key}] already exists.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$parent = trim((string) $this->option('parent'));
|
||||
$name = trim((string) ($this->option('name') ?: str($key)->headline()));
|
||||
$this->files->ensureDirectoryExists($path.'/views');
|
||||
|
||||
$manifest = [
|
||||
'manifest_version' => 1,
|
||||
'name' => $name,
|
||||
'slug' => $key,
|
||||
'version' => '1.0.0',
|
||||
'description' => "Child theme for {$name}.",
|
||||
'extends' => $parent !== '' ? $parent : null,
|
||||
'entry_view' => 'home',
|
||||
'views' => true,
|
||||
'variables' => [],
|
||||
'settings' => [],
|
||||
];
|
||||
|
||||
if ($manifest['extends'] === null) {
|
||||
unset($manifest['extends']);
|
||||
}
|
||||
|
||||
$this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
|
||||
$this->files->put($path.'/views/.gitkeep', '');
|
||||
|
||||
$this->info("Theme [{$key}] created at {$path}.");
|
||||
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class FindDuplicateViewsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:duplicates';
|
||||
protected $description = 'Find Blade task paths owned by more than one theme.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$duplicates = $this->inspector->duplicateViews();
|
||||
if ($duplicates === []) {
|
||||
$this->info('No duplicate theme views found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($duplicates as $view => $owners) {
|
||||
foreach ($owners as $owner) {
|
||||
$rows[] = [$view, $owner['theme'], $owner['path']];
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['View', 'Theme', 'Path'], $rows);
|
||||
$this->error(count($duplicates).' duplicate view task(s) found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class InspectThemesCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:inspect';
|
||||
protected $description = 'Display installed themes, inheritance, entry views, and view counts.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$themes = $this->inspector->themes();
|
||||
$this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [
|
||||
$theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'],
|
||||
], $themes));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class PublishAssetsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}';
|
||||
protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'stylist-config',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
$themesPath = (string) config('stylist.themes_path', resource_path('themes'));
|
||||
|
||||
if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) {
|
||||
$this->components->error("Unable to create theme directory: {$themesPath}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info("Stylist config published. Themes remain in: {$themesPath}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class ThemeDoctorCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:doctor';
|
||||
protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$issues = $this->inspector->issues();
|
||||
if ($issues === []) {
|
||||
$this->info('Stylist is healthy. No theme issues found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [
|
||||
strtoupper($issue['severity']), $issue['theme'], $issue['message'],
|
||||
], $issues));
|
||||
|
||||
$errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error'));
|
||||
$warnings = count($issues) - $errors;
|
||||
$this->line("{$errors} error(s), {$warnings} warning(s).");
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user