style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
@@ -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.');
}
@@ -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;
}
+2 -1
View File
@@ -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();
}
+14 -4
View File
@@ -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() === []) {
+30 -9
View File
@@ -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))]);
+2
View File
@@ -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;
}
}
+19 -5
View File
@@ -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;
}
}
+24 -7
View File
@@ -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());
+8 -4
View File
@@ -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;
}
+38 -11
View File
@@ -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
View File
@@ -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;
}
}
+7 -1
View File
@@ -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, '/')]);
}
}