style updates
This commit is contained in:
@@ -8,6 +8,7 @@ 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)
|
||||
@@ -20,12 +21,14 @@ final class CreateThemeCommand extends Command
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ final class CreateThemeCommand extends Command
|
||||
|
||||
$this->info("Theme [{$key}] created at {$path}.");
|
||||
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
@@ -20,6 +21,7 @@ final class FindDuplicateViewsCommand extends Command
|
||||
$duplicates = $this->inspector->duplicateViews();
|
||||
if ($duplicates === []) {
|
||||
$this->info('No duplicate theme views found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ final class FindDuplicateViewsCommand extends Command
|
||||
|
||||
$this->table(['View', 'Theme', 'Path'], $rows);
|
||||
$this->error(count($duplicates).' duplicate view task(s) found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
@@ -20,6 +21,7 @@ final class ThemeDoctorCommand extends Command
|
||||
$issues = $this->inspector->issues();
|
||||
if ($issues === []) {
|
||||
$this->info('Stylist is healthy. No theme issues found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ final class ValidateThemeCommand extends Command
|
||||
|
||||
if ($issues === []) {
|
||||
$this->info('Theme validation passed with no issues.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ final class ValidateThemeCommand extends Command
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
if ($temporary !== null) {
|
||||
@@ -75,7 +77,7 @@ final class ValidateThemeCommand extends Command
|
||||
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
|
||||
$this->files->ensureDirectoryExists($temporary);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($input) !== true) {
|
||||
throw new RuntimeException('The theme ZIP could not be opened.');
|
||||
}
|
||||
|
||||
+17
-3
@@ -4,7 +4,21 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void { Schema::create('stylist_theme_settings', function (Blueprint $table) { $table->id(); $table->string('theme_key')->unique(); $table->string('variant')->default('light'); $table->json('settings')->nullable(); $table->timestamps(); }); }
|
||||
public function down(): void { Schema::dropIfExists('stylist_theme_settings'); }
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stylist_theme_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('theme_key')->unique();
|
||||
$table->string('variant')->default('light');
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stylist_theme_settings');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,6 +120,7 @@ final class ThemeInspector
|
||||
}
|
||||
|
||||
ksort($views);
|
||||
|
||||
return $views;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Modules\Stylist\Html\ThemeHtmlBuilder;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Modules\Stylist\Theme\UrlGenerator;
|
||||
@@ -42,7 +43,7 @@ if (! function_exists('theme_asset')) {
|
||||
}
|
||||
|
||||
if (! function_exists('theme_switcher')) {
|
||||
function theme_switcher(): \Illuminate\Support\HtmlString
|
||||
function theme_switcher(): HtmlString
|
||||
{
|
||||
return stylist_html()->switcher();
|
||||
}
|
||||
|
||||
@@ -13,14 +13,20 @@ final class ThemeHtmlBuilder
|
||||
public function stylesheet(?string $path = null): HtmlString
|
||||
{
|
||||
$path ??= $this->stylist->current()->stylesheet();
|
||||
if (! $this->stylist->current()->has($path)) return new HtmlString('');
|
||||
if (! $this->stylist->current()->has($path)) {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
return new HtmlString('<link rel="stylesheet" href="'.e($this->url->asset($path)).'">');
|
||||
}
|
||||
|
||||
public function script(?string $path = null): HtmlString
|
||||
{
|
||||
$path ??= $this->stylist->current()->script();
|
||||
if (! $this->stylist->current()->has($path)) return new HtmlString('');
|
||||
if (! $this->stylist->current()->has($path)) {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
return new HtmlString('<script src="'.e($this->url->asset($path)).'" defer></script>');
|
||||
}
|
||||
|
||||
@@ -32,11 +38,15 @@ final class ThemeHtmlBuilder
|
||||
|
||||
foreach ($theme->appearances() as $name => $definition) {
|
||||
$vars = (array) data_get($definition, 'variables', []);
|
||||
if ($vars) $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
if ($vars) {
|
||||
$rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
}
|
||||
}
|
||||
foreach ($theme->accents() as $name => $definition) {
|
||||
$vars = (array) data_get($definition, 'variables', []);
|
||||
if ($vars) $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
if ($vars) {
|
||||
$rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
}
|
||||
}
|
||||
|
||||
if ($theme->appearances() === [] && $theme->accents() === []) {
|
||||
|
||||
@@ -9,15 +9,36 @@ use Modules\Stylist\Theme\UrlGenerator;
|
||||
final class ThemeSeoBuilder
|
||||
{
|
||||
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {}
|
||||
public function render(array $overrides=[]): HtmlString
|
||||
|
||||
public function render(array $overrides = []): HtmlString
|
||||
{
|
||||
$theme=$this->stylist->current(); $seo=array_replace($theme->seo(),$overrides); $settings=$this->stylist->settings();
|
||||
$title=(string)($seo['title']??$settings['site_title']??$theme->name()); $description=(string)($seo['description']??$settings['site_description']??$theme->description());
|
||||
$image=$seo['image']??$theme->preview(); if($image && $theme->has($image))$image=$this->urls->asset($image);
|
||||
$tags=['<title>'.e($title).'</title>']; if($description!=='')$tags[]='<meta name="description" content="'.e($description).'">';
|
||||
foreach(['og:title'=>$title,'og:description'=>$description,'twitter:card'=>'summary_large_image','twitter:title'=>$title,'twitter:description'=>$description] as $property=>$content){if($content==='')continue;$attribute=str_starts_with($property,'og:')?'property':'name';$tags[]='<meta '.$attribute.'="'.e($property).'" content="'.e($content).'">';}
|
||||
if($image){$tags[]='<meta property="og:image" content="'.e($image).'">';$tags[]='<meta name="twitter:image" content="'.e($image).'">';}
|
||||
if(isset($seo['json_ld'])&&is_array($seo['json_ld']))$tags[]='<script type="application/ld+json">'.json_encode($seo['json_ld'],JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).'</script>';
|
||||
return new HtmlString(implode("\n",$tags));
|
||||
$theme = $this->stylist->current();
|
||||
$seo = array_replace($theme->seo(), $overrides);
|
||||
$settings = $this->stylist->settings();
|
||||
$title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name());
|
||||
$description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description());
|
||||
$image = $seo['image'] ?? $theme->preview();
|
||||
if ($image && $theme->has($image)) {
|
||||
$image = $this->urls->asset($image);
|
||||
}
|
||||
$tags = ['<title>'.e($title).'</title>'];
|
||||
if ($description !== '') {
|
||||
$tags[] = '<meta name="description" content="'.e($description).'">';
|
||||
}
|
||||
foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) {
|
||||
if ($content === '') {
|
||||
continue;
|
||||
}$attribute = str_starts_with($property, 'og:') ? 'property' : 'name';
|
||||
$tags[] = '<meta '.$attribute.'="'.e($property).'" content="'.e($content).'">';
|
||||
}
|
||||
if ($image) {
|
||||
$tags[] = '<meta property="og:image" content="'.e($image).'">';
|
||||
$tags[] = '<meta name="twitter:image" content="'.e($image).'">';
|
||||
}
|
||||
if (isset($seo['json_ld']) && is_array($seo['json_ld'])) {
|
||||
$tags[] = '<script type="application/ld+json">'.json_encode($seo['json_ld'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).'</script>';
|
||||
}
|
||||
|
||||
return new HtmlString(implode("\n", $tags));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,29 +22,42 @@ final class ThemeManagementController extends Controller
|
||||
$request->string('appearance')->toString() ?: null,
|
||||
$request->string('accent')->toString() ?: null,
|
||||
);
|
||||
|
||||
return back()->with('status', 'Theme settings saved.');
|
||||
}
|
||||
|
||||
public function install(Request $request, ThemePackageManager $packages)
|
||||
{
|
||||
$request->validate(['theme' => ['required','file','mimes:zip','max:51200']]);
|
||||
$request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]);
|
||||
$theme = $packages->install($request->file('theme'));
|
||||
|
||||
return back()->with('status', $theme->name().' installed.');
|
||||
}
|
||||
|
||||
public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
|
||||
{
|
||||
$item = $loader->find($theme); $packages->uninstall($item);
|
||||
$item = $loader->find($theme);
|
||||
$packages->uninstall($item);
|
||||
|
||||
return back()->with('status', $item->name().' uninstalled.');
|
||||
}
|
||||
|
||||
public function export(string $theme, Loader $loader, ThemePackageManager $packages)
|
||||
{
|
||||
$item = $loader->find($theme); $path = $packages->export($item);
|
||||
$item = $loader->find($theme);
|
||||
$path = $packages->export($item);
|
||||
|
||||
return response()->download($path)->deleteFileAfterSend();
|
||||
}
|
||||
|
||||
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
|
||||
{
|
||||
$item = $loader->find($theme); $result = $optimizer->optimize($item);
|
||||
$item = $loader->find($theme);
|
||||
$result = $optimizer->optimize($item);
|
||||
|
||||
return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
|
||||
}
|
||||
|
||||
public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
|
||||
{
|
||||
return response()->json(['issues' => $validator->validate($loader->find($theme))]);
|
||||
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
final class ThemeSetting extends Model
|
||||
{
|
||||
protected $table = 'stylist_theme_settings';
|
||||
|
||||
protected $fillable = ['theme_key', 'variant', 'settings'];
|
||||
|
||||
protected $casts = ['settings' => 'array'];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Modules\Stylist\Theme\Stylist;
|
||||
final class StylistServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $moduleName = 'Stylist';
|
||||
|
||||
protected string $moduleNameLower = 'stylist';
|
||||
|
||||
public function register(): void
|
||||
@@ -135,7 +136,6 @@ final class StylistServiceProvider extends ServiceProvider
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function normalisePath(string $path): string
|
||||
{
|
||||
return rtrim(
|
||||
|
||||
@@ -26,6 +26,7 @@ final class ThemeAccessibilityValidator
|
||||
$issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ final class ThemeOptimizer
|
||||
$result = ['css' => 0, 'js' => 0, 'images' => 0];
|
||||
foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) {
|
||||
$directory = $theme->path().'/'.$folder;
|
||||
if (! $this->files->isDirectory($directory)) continue;
|
||||
if (! $this->files->isDirectory($directory)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->files->files($directory) as $file) {
|
||||
if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) continue;
|
||||
if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) {
|
||||
continue;
|
||||
}
|
||||
$content = $file->getContents();
|
||||
$content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content;
|
||||
$content = preg_replace('/\s+/', ' ', $content) ?? $content;
|
||||
@@ -29,15 +33,25 @@ final class ThemeOptimizer
|
||||
if (function_exists('imagewebp')) {
|
||||
foreach (['images', 'icons'] as $folder) {
|
||||
$directory = $theme->path().'/'.$folder;
|
||||
if (! $this->files->isDirectory($directory)) continue;
|
||||
if (! $this->files->isDirectory($directory)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->files->files($directory) as $file) {
|
||||
$ext = strtolower($file->getExtension());
|
||||
if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) continue;
|
||||
if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
|
||||
continue;
|
||||
}
|
||||
$image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname());
|
||||
if ($image) { imagepalettetotruecolor($image); imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82); imagedestroy($image); $result['images']++; }
|
||||
if ($image) {
|
||||
imagepalettetotruecolor($image);
|
||||
imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82);
|
||||
imagedestroy($image);
|
||||
$result['images']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,28 +15,46 @@ final class ThemePackageManager
|
||||
|
||||
public function install(UploadedFile $upload): Theme
|
||||
{
|
||||
if (strtolower($upload->getClientOriginalExtension()) !== 'zip') throw new RuntimeException('Only ZIP theme packages may be installed.');
|
||||
if (strtolower($upload->getClientOriginalExtension()) !== 'zip') {
|
||||
throw new RuntimeException('Only ZIP theme packages may be installed.');
|
||||
}
|
||||
$temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8)));
|
||||
$this->files->ensureDirectoryExists($temporary);
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($upload->getRealPath()) !== true) throw new RuntimeException('The uploaded ZIP could not be opened.');
|
||||
for ($i=0; $i<$zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { $zip->close(); throw new RuntimeException('The ZIP contains an unsafe path.'); }
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($upload->getRealPath()) !== true) {
|
||||
throw new RuntimeException('The uploaded ZIP could not be opened.');
|
||||
}
|
||||
$zip->extractTo($temporary); $zip->close();
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
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) { $this->files->deleteDirectory($temporary); throw new RuntimeException('The package does not contain theme.json.'); }
|
||||
if (! $manifest) {
|
||||
$this->files->deleteDirectory($temporary);
|
||||
throw new RuntimeException('The package does not contain theme.json.');
|
||||
}
|
||||
$source = $manifest->getPath();
|
||||
$theme = $this->loader->fromPath($source);
|
||||
if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
|
||||
if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) {
|
||||
throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
|
||||
}
|
||||
$issues = $this->validator->validate($theme);
|
||||
if (collect($issues)->contains('severity', 'error')) throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
|
||||
if (collect($issues)->contains('severity', 'error')) {
|
||||
throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
|
||||
}
|
||||
$target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
|
||||
if ($this->files->isDirectory($target)) $this->files->deleteDirectory($target);
|
||||
if ($this->files->isDirectory($target)) {
|
||||
$this->files->deleteDirectory($target);
|
||||
}
|
||||
$this->files->copyDirectory($source, $target);
|
||||
$this->files->deleteDirectory($temporary);
|
||||
$this->loader->flush();
|
||||
|
||||
return $this->loader->find($theme->key());
|
||||
}
|
||||
|
||||
@@ -44,17 +62,30 @@ final class ThemePackageManager
|
||||
{
|
||||
$projectRoot = realpath((string) config('stylist.themes_path'));
|
||||
$themePath = realpath($theme->path());
|
||||
if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) throw new RuntimeException('Bundled themes cannot be uninstalled.');
|
||||
if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) throw new RuntimeException('This theme has installed child themes.');
|
||||
$this->files->deleteDirectory($themePath); $this->loader->flush();
|
||||
if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('Bundled themes cannot be uninstalled.');
|
||||
}
|
||||
if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) {
|
||||
throw new RuntimeException('This theme has installed child themes.');
|
||||
}
|
||||
$this->files->deleteDirectory($themePath);
|
||||
$this->loader->flush();
|
||||
}
|
||||
|
||||
public function export(Theme $theme): string
|
||||
{
|
||||
$directory = storage_path('app/stylist/exports'); $this->files->ensureDirectoryExists($directory);
|
||||
$directory = storage_path('app/stylist/exports');
|
||||
$this->files->ensureDirectoryExists($directory);
|
||||
$path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip';
|
||||
$zip = new ZipArchive(); if ($zip->open($path, ZipArchive::CREATE|ZipArchive::OVERWRITE) !== true) throw new RuntimeException('Unable to create theme archive.');
|
||||
foreach ($this->files->allFiles($theme->path()) as $file) $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
|
||||
$zip->close(); return $path;
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Unable to create theme archive.');
|
||||
}
|
||||
foreach ($this->files->allFiles($theme->path()) as $file) {
|
||||
$zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Modules\Stylist\Theme\Theme;
|
||||
final class ThemeSettings
|
||||
{
|
||||
private const APPEARANCE_KEY = '__stylist_appearance';
|
||||
|
||||
private const ACCENT_KEY = '__stylist_accent';
|
||||
|
||||
public function get(Theme $theme): array
|
||||
@@ -24,6 +25,7 @@ final class ThemeSettings
|
||||
public function variant(Theme $theme): string
|
||||
{
|
||||
$variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light');
|
||||
|
||||
return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light');
|
||||
}
|
||||
|
||||
@@ -31,8 +33,11 @@ final class ThemeSettings
|
||||
{
|
||||
$stored = (array) ($this->stored($theme)?->settings ?? []);
|
||||
$allowed = array_keys($theme->appearances());
|
||||
if ($allowed === []) return '';
|
||||
if ($allowed === []) {
|
||||
return '';
|
||||
}
|
||||
$appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
|
||||
|
||||
return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
|
||||
}
|
||||
|
||||
@@ -40,8 +45,11 @@ final class ThemeSettings
|
||||
{
|
||||
$stored = (array) ($this->stored($theme)?->settings ?? []);
|
||||
$allowed = array_keys($theme->accents());
|
||||
if ($allowed === []) return '';
|
||||
if ($allowed === []) {
|
||||
return '';
|
||||
}
|
||||
$accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
|
||||
|
||||
return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
|
||||
}
|
||||
|
||||
@@ -53,18 +61,24 @@ final class ThemeSettings
|
||||
$allowedAppearances = array_keys($theme->appearances());
|
||||
if ($allowedAppearances !== []) {
|
||||
$appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
|
||||
if (! in_array($appearance, $allowedAppearances, true)) $appearance = (string) array_key_first($theme->appearances());
|
||||
if (! in_array($appearance, $allowedAppearances, true)) {
|
||||
$appearance = (string) array_key_first($theme->appearances());
|
||||
}
|
||||
$settings[self::APPEARANCE_KEY] = $appearance;
|
||||
}
|
||||
|
||||
$allowedAccents = array_keys($theme->accents());
|
||||
if ($allowedAccents !== []) {
|
||||
$accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent());
|
||||
if (! in_array($accent, $allowedAccents, true)) $accent = (string) array_key_first($theme->accents());
|
||||
if (! in_array($accent, $allowedAccents, true)) {
|
||||
$accent = (string) array_key_first($theme->accents());
|
||||
}
|
||||
$settings[self::ACCENT_KEY] = $accent;
|
||||
}
|
||||
|
||||
if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) $variant = array_key_first($theme->variants());
|
||||
if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) {
|
||||
$variant = array_key_first($theme->variants());
|
||||
}
|
||||
|
||||
return ThemeSetting::query()->updateOrCreate(
|
||||
['theme_key' => $theme->key()],
|
||||
@@ -74,7 +88,10 @@ final class ThemeSettings
|
||||
|
||||
private function stored(Theme $theme): ?ThemeSetting
|
||||
{
|
||||
try { return ThemeSetting::query()->where('theme_key', $theme->key())->first(); }
|
||||
catch (QueryException) { return null; }
|
||||
try {
|
||||
return ThemeSetting::query()->where('theme_key', $theme->key())->first();
|
||||
} catch (QueryException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ final class ThemeManifestTest extends TestCase
|
||||
$this->expectException(InvalidThemeManifestException::class);
|
||||
app(Loader::class)->fromPath($path);
|
||||
}
|
||||
|
||||
public function test_phase_one_manifest_optional_capabilities_are_normalised(): void
|
||||
{
|
||||
$path = $this->themesPath.'/design-gallery';
|
||||
@@ -153,5 +154,4 @@ final class ThemeManifestTest extends TestCase
|
||||
|
||||
$this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use ZipArchive;
|
||||
final class ThemePackageRoundTripTest extends TestCase
|
||||
{
|
||||
private string $themesPath;
|
||||
|
||||
private string $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -58,7 +59,7 @@ final class ThemePackageRoundTripTest extends TestCase
|
||||
file_put_contents($source.'/views/home.blade.php', '<main>Design Gallery works</main>');
|
||||
|
||||
$zipPath = $this->workspace.'/design-gallery.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip = new ZipArchive;
|
||||
$this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE));
|
||||
foreach (app(Filesystem::class)->allFiles($source) as $file) {
|
||||
$zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname());
|
||||
|
||||
@@ -104,8 +104,8 @@ final class ManifestValidator
|
||||
* Compact lists such as ["light", "dark"] are accepted and converted to
|
||||
* keyed empty definitions. Object definitions remain unchanged.
|
||||
*
|
||||
* @param array<int|string, mixed> $value
|
||||
* @param array<int, string> $errors
|
||||
* @param array<int|string, mixed> $value
|
||||
* @param array<int, string> $errors
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
|
||||
@@ -125,10 +125,12 @@ final class ManifestValidator
|
||||
foreach ($value as $name) {
|
||||
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
|
||||
$errors[] = "Every {$field} entry must be a valid lowercase key.";
|
||||
|
||||
continue;
|
||||
}
|
||||
$normalised[$name] = [];
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
@@ -136,6 +138,7 @@ final class ManifestValidator
|
||||
foreach ($value as $name => $definition) {
|
||||
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
|
||||
$errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
|
||||
|
||||
continue;
|
||||
}
|
||||
$normalised[$name] = $definition;
|
||||
@@ -145,8 +148,8 @@ final class ManifestValidator
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $value
|
||||
* @param array<int, string> $errors
|
||||
* @param array<int|string, mixed> $value
|
||||
* @param array<int, string> $errors
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
private function normaliseCapabilities(array $value, array &$errors): array
|
||||
@@ -156,6 +159,7 @@ final class ManifestValidator
|
||||
foreach ($value as $name => $enabled) {
|
||||
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
|
||||
$errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ final class Stylist
|
||||
public function current(): Theme
|
||||
{
|
||||
$requested = $this->resolver->resolve();
|
||||
try { return $this->loader->find($requested); }
|
||||
catch (ThemeNotFoundException) {
|
||||
try {
|
||||
return $this->loader->find($requested);
|
||||
} catch (ThemeNotFoundException) {
|
||||
$fallback = (string) config('stylist.default_theme', 'default');
|
||||
if ($requested !== $fallback) return $this->loader->find($fallback);
|
||||
if ($requested !== $fallback) {
|
||||
return $this->loader->find($fallback);
|
||||
}
|
||||
throw ThemeNotFoundException::forKey($requested);
|
||||
}
|
||||
}
|
||||
@@ -44,21 +47,45 @@ final class Stylist
|
||||
);
|
||||
}
|
||||
|
||||
public function settings(): array { return $this->settings->get($this->current()); }
|
||||
public function variant(): string { return $this->settings->variant($this->current()); }
|
||||
public function appearance(): string { return $this->settings->appearance($this->current()); }
|
||||
public function accent(): string { return $this->settings->accent($this->current()); }
|
||||
public function all() { return $this->loader->all(); }
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->settings->get($this->current());
|
||||
}
|
||||
|
||||
public function variant(): string
|
||||
{
|
||||
return $this->settings->variant($this->current());
|
||||
}
|
||||
|
||||
public function appearance(): string
|
||||
{
|
||||
return $this->settings->appearance($this->current());
|
||||
}
|
||||
|
||||
public function accent(): string
|
||||
{
|
||||
return $this->settings->accent($this->current());
|
||||
}
|
||||
|
||||
public function all()
|
||||
{
|
||||
return $this->loader->all();
|
||||
}
|
||||
|
||||
private function inheritedVariables(Theme $theme, array $seen = []): array
|
||||
{
|
||||
if (in_array($theme->key(), $seen, true)) return [];
|
||||
if (in_array($theme->key(), $seen, true)) {
|
||||
return [];
|
||||
}
|
||||
$seen[] = $theme->key();
|
||||
$parent = [];
|
||||
if ($theme->parent()) {
|
||||
try { $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); }
|
||||
catch (ThemeNotFoundException) {}
|
||||
try {
|
||||
$parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
|
||||
} catch (ThemeNotFoundException) {
|
||||
}
|
||||
}
|
||||
|
||||
return array_replace($parent, $theme->variables());
|
||||
}
|
||||
}
|
||||
|
||||
+137
-27
@@ -14,33 +14,143 @@ final class Theme
|
||||
private readonly array $meta = [],
|
||||
) {}
|
||||
|
||||
public function key(): string { return $this->key; }
|
||||
public function name(): string { return $this->name; }
|
||||
public function path(): string { return $this->path; }
|
||||
public function stylesheet(): string { return $this->stylesheet; }
|
||||
public function script(): string { return $this->script; }
|
||||
public function variables(): array { return $this->variables; }
|
||||
public function meta(): array { return $this->meta; }
|
||||
public function manifestVersion(): int { return (int) ($this->meta['manifest_version'] ?? 1); }
|
||||
public function version(): string { return (string) ($this->meta['version'] ?? '1.0.0'); }
|
||||
public function description(): string { return (string) ($this->meta['description'] ?? ''); }
|
||||
public function author(): string { return (string) ($this->meta['author'] ?? ''); }
|
||||
public function parent(): ?string { $parent = $this->meta['extends'] ?? null; return is_string($parent) && $parent !== '' ? $parent : null; }
|
||||
public function preview(): ?string { $preview = $this->meta['preview'] ?? null; return is_string($preview) && $preview !== '' ? $preview : null; }
|
||||
public function variants(): array { return (array) ($this->meta['variants'] ?? []); }
|
||||
public function appearances(): array { return (array) ($this->meta['appearances'] ?? []); }
|
||||
public function accents(): array { return (array) ($this->meta['accents'] ?? []); }
|
||||
public function defaultAppearance(): string { return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? ''); }
|
||||
public function defaultAccent(): string { return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? ''); }
|
||||
public function settingsSchema(): array { return (array) ($this->meta['settings'] ?? []); }
|
||||
public function seo(): array { return (array) ($this->meta['seo'] ?? []); }
|
||||
public function entryView(): string { return (string) ($this->meta['entry_view'] ?? 'home'); }
|
||||
public function capabilities(): array { return (array) ($this->meta['capabilities'] ?? []); }
|
||||
public function hasCapability(string $feature): bool { return (bool) ($this->capabilities()[$feature] ?? false); }
|
||||
public function supports(string $feature): bool { return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature)); }
|
||||
public function has(string $relativePath): bool { return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/')); }
|
||||
public function absolutePath(string $relativePath): string { return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'); }
|
||||
public function key(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function stylesheet(): string
|
||||
{
|
||||
return $this->stylesheet;
|
||||
}
|
||||
|
||||
public function script(): string
|
||||
{
|
||||
return $this->script;
|
||||
}
|
||||
|
||||
public function variables(): array
|
||||
{
|
||||
return $this->variables;
|
||||
}
|
||||
|
||||
public function meta(): array
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
public function manifestVersion(): int
|
||||
{
|
||||
return (int) ($this->meta['manifest_version'] ?? 1);
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return (string) ($this->meta['version'] ?? '1.0.0');
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return (string) ($this->meta['description'] ?? '');
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return (string) ($this->meta['author'] ?? '');
|
||||
}
|
||||
|
||||
public function parent(): ?string
|
||||
{
|
||||
$parent = $this->meta['extends'] ?? null;
|
||||
|
||||
return is_string($parent) && $parent !== '' ? $parent : null;
|
||||
}
|
||||
|
||||
public function preview(): ?string
|
||||
{
|
||||
$preview = $this->meta['preview'] ?? null;
|
||||
|
||||
return is_string($preview) && $preview !== '' ? $preview : null;
|
||||
}
|
||||
|
||||
public function variants(): array
|
||||
{
|
||||
return (array) ($this->meta['variants'] ?? []);
|
||||
}
|
||||
|
||||
public function appearances(): array
|
||||
{
|
||||
return (array) ($this->meta['appearances'] ?? []);
|
||||
}
|
||||
|
||||
public function accents(): array
|
||||
{
|
||||
return (array) ($this->meta['accents'] ?? []);
|
||||
}
|
||||
|
||||
public function defaultAppearance(): string
|
||||
{
|
||||
return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? '');
|
||||
}
|
||||
|
||||
public function defaultAccent(): string
|
||||
{
|
||||
return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? '');
|
||||
}
|
||||
|
||||
public function settingsSchema(): array
|
||||
{
|
||||
return (array) ($this->meta['settings'] ?? []);
|
||||
}
|
||||
|
||||
public function seo(): array
|
||||
{
|
||||
return (array) ($this->meta['seo'] ?? []);
|
||||
}
|
||||
|
||||
public function entryView(): string
|
||||
{
|
||||
return (string) ($this->meta['entry_view'] ?? 'home');
|
||||
}
|
||||
|
||||
public function capabilities(): array
|
||||
{
|
||||
return (array) ($this->meta['capabilities'] ?? []);
|
||||
}
|
||||
|
||||
public function hasCapability(string $feature): bool
|
||||
{
|
||||
return (bool) ($this->capabilities()[$feature] ?? false);
|
||||
}
|
||||
|
||||
public function supports(string $feature): bool
|
||||
{
|
||||
return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature));
|
||||
}
|
||||
|
||||
public function has(string $relativePath): bool
|
||||
{
|
||||
return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'));
|
||||
}
|
||||
|
||||
public function absolutePath(string $relativePath): string
|
||||
{
|
||||
return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/');
|
||||
}
|
||||
|
||||
/** @deprecated Theme assets are served through UrlGenerator. */
|
||||
public function assetsPath(): string { return 'stylist/theme-assets/'.$this->key; }
|
||||
public function assetsPath(): string
|
||||
{
|
||||
return 'stylist/theme-assets/'.$this->key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ namespace Modules\Stylist\Theme;
|
||||
final class UrlGenerator
|
||||
{
|
||||
public function __construct(private readonly Stylist $stylist) {}
|
||||
public function asset(string $path): string { return $this->forTheme($this->stylist->current(), $path); }
|
||||
|
||||
public function asset(string $path): string
|
||||
{
|
||||
return $this->forTheme($this->stylist->current(), $path);
|
||||
}
|
||||
|
||||
public function forTheme(Theme|string $theme, string $path): string
|
||||
{
|
||||
$key = $theme instanceof Theme ? $theme->key() : $theme;
|
||||
|
||||
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user