27 Commits
Author SHA1 Message Date
Mark Gregory 976f161550 fix: preserve empty Laravel views directory for deployment 2026-10-20 10:57:19 +01:00
Mark Gregory 1424133b0a added theme support 2026-10-20 10:55:54 +01:00
Mark Gregory 835008e99d added theme support 2026-10-20 10:53:10 +01:00
Mark Gregory e926301c12 added theme support 2026-10-20 10:46:10 +01:00
Mark Gregory 1f17a98158 added theme support 2026-10-20 10:44:42 +01:00
Mark Gregory a0dc4787ab added theme support 2026-10-20 10:41:40 +01:00
Mark Gregory 8854604593 added theme support 2026-10-20 10:40:24 +01:00
Mark Gregory b159b2e16c added theme support 2026-10-20 10:38:03 +01:00
Mark Gregory c198374d49 added theme support 2026-10-20 10:37:13 +01:00
Mark Gregory 63582531e3 added theme support 2026-10-20 10:29:35 +01:00
Mark Gregory c5353d0351 added theme support 2026-10-20 10:27:30 +01:00
Mark Gregory f0e31aad6b added theme support 2026-10-20 10:23:23 +01:00
Mark Gregory 1d70a9c8cf added theme support 2026-10-20 10:22:21 +01:00
Mark Gregory 42c074d966 added theme support 2026-10-20 10:19:18 +01:00
Mark Gregory c5f6871e8d download fixes 2026-10-20 10:17:23 +01:00
Mark Gregory b02d4259c3 download fixes 2026-10-20 10:16:48 +01:00
Mark Gregory 3f54289a9d download fixes 2026-10-20 10:10:34 +01:00
Mark Gregory 5d0faef236 download fixes 2026-10-20 10:10:06 +01:00
Mark Gregory f8e1170d5f download fixes 2026-10-20 10:06:04 +01:00
Mark Gregory ec13e7339a download fixes 2026-10-20 09:59:26 +01:00
Mark Gregory bfa4a2312c download fixes 2026-10-20 09:57:43 +01:00
Mark Gregory 1dab26931a update 2026-10-20 09:49:44 +01:00
Mark Gregory 1409d19e3d download fixes 2026-10-12 10:56:55 +01:00
Mark Gregory f9123813fb download fixes 2026-10-12 10:54:23 +01:00
Mark Gregory 9c3353924b download fixes 2026-10-12 10:50:27 +01:00
Mark Gregory d415b42044 download fixes 2026-10-12 10:48:30 +01:00
Mark Gregory d94845b1f6 download fixes 2026-10-12 10:32:50 +01:00
138 changed files with 5278 additions and 225 deletions
View File
@@ -1,45 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CmsPage;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class CmsPageController extends Controller
{
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
private function validated(Request $request, ?CmsPage $page = null): array
{
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
$data = $request->validate([
'title' => ['required','string','max:160'],
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
'excerpt' => ['nullable','string','max:1000'],
'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'],
'meta_keywords' => ['nullable','string','max:500'],
'canonical_url' => ['nullable','url:http,https','max:2048'],
'og_title' => ['nullable','string','max:160'],
'og_description' => ['nullable','string','max:320'],
'og_image' => ['nullable','url:http,https','max:2048'],
'sitemap_priority' => ['required','numeric','min:0','max:1'],
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]);
$data['robots_index'] = $request->boolean('robots_index');
$data['robots_follow'] = $request->boolean('robots_follow');
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
}
}
@@ -1,50 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SeoSettingsController extends Controller
{
private const KEYS = [
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
];
public function edit(): View
{
return view('admin.seo.edit', [
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'seo_site_name' => ['required', 'string', 'max:80'],
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
'seo_default_title' => ['required', 'string', 'max:160'],
'seo_default_description' => ['required', 'string', 'max:320'],
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
'seo_organization_name' => ['nullable', 'string', 'max:120'],
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
]);
foreach (self::KEYS as $key) {
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
}
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
return back()->with('status', 'SEO settings updated.');
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
use App\Models\SiteSetting;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
final class SeoController extends Controller
{
public function sitemap(): Response
{
$static = collect([
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
])->filter(fn (array $item) => Route::has($item['route']))->values();
$pages = CmsPage::query()
->where('is_published', true)
->where('include_in_sitemap', true)
->where('robots_index', true)
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->orderBy('slug')
->get();
return response()
->view('seo.sitemap', compact('static', 'pages'))
->header('Content-Type', 'application/xml; charset=UTF-8')
->header('Cache-Control', 'public, max-age=3600');
}
public function robots(): Response
{
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
$body = $indexable
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($body, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
protected $fillable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
];
protected function casts(): array
{
return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
final class SiteSetting extends Model
{
protected $table = 'sonardesk_site_settings';
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['key', 'value'];
public static function valueFor(string $key, ?string $default = null): ?string
{
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
}
public static function booleanFor(string $key, bool $default = false): bool
{
$value = static::valueFor($key);
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget('sonardesk.setting.'.$key);
}
}
@@ -8,14 +8,14 @@ use Illuminate\Support\Facades\Storage;
final class DownloadProductController extends Controller {
public function index() { return view('admin.download-products.index', ['products'=>DownloadProduct::latest()->get()]); }
public function store(Request $request) {
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:3000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'required|file|max:1048576','is_published'=>'sometimes|boolean']);
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'required|file|max:1048576','is_published'=>'sometimes|boolean']);
$file=$request->file('package');
$path=$file->store('download-products','local');
DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>$request->boolean('is_published')]);
return back()->with('status','Product added.');
}
public function update(Request $request, DownloadProduct $product) {
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:3000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'nullable|file|max:1048576','is_published'=>'sometimes|boolean']);
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'nullable|file|max:1048576','is_published'=>'sometimes|boolean']);
$changes=['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'is_published'=>$request->boolean('is_published')];
if ($request->hasFile('package')) { $file=$request->file('package'); $path=$file->store('download-products','local'); $old=$product->file_path; $changes+=['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()]; $product->update($changes); if($old) Storage::disk('local')->delete($old); }
else $product->update($changes);
@@ -26,6 +26,14 @@ final class ReleaseController extends Controller
return view('downloads.index', compact('latest', 'releases', 'settings', 'products'));
}
public function sonarDeskDetails()
{
$latest = SonarDeskRelease::published()->where('is_featured', true)->orderByDesc('published_at')->first()
?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->firstOrFail();
$settings = $this->settings();
return view('downloads.sonardesk', compact('latest', 'settings'));
}
public function latest(): BinaryFileResponse
{
$release = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->firstOrFail();
@@ -38,6 +46,12 @@ final class ReleaseController extends Controller
return $this->send($release);
}
public function productDetails(DownloadProduct $product)
{
abort_unless($product->is_published && $product->file_path, 404);
return view('downloads.product', compact('product'));
}
public function productDownload(DownloadProduct $product): BinaryFileResponse
{
abort_unless($product->is_published && $product->file_path && Storage::disk('local')->exists($product->file_path), 404);
-4
View File
@@ -9,9 +9,5 @@ class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Blade::anonymousComponentPath(
resource_path('views/components/layouts'),
'layouts'
);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->web(append: [\Modules\Stylist\Http\Middleware\ApplyActiveTheme::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+6 -2
View File
@@ -18,21 +18,24 @@ add('shared_dirs', ['storage']);
add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
host('main')
host('prod')
->set('hostname', '217.154.47.197')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('php_version', '8.5')
->set('deploy_path', '/var/www/sonardesk/prod');
host('stage')
->set('hostname', '192.168.1.2')
->set('remote_user', 'mag')
->set('branch', 'stage')
->set('php_version', '8.3')
->set('deploy_path', '/var/www/sonardesk/stage');
host('develop')
->set('hostname', '10.211.55.100')
->set('remote_user', 'mag')
->set('php_version', '8.3')
->set('branch', 'develop')
->set('deploy_path', '/var/www/sonardesk/develop');
@@ -54,9 +57,10 @@ task('artisan:clear', function (): void {
run('cd {{release_path}} && php artisan route:clear');
});
task('services:restart', function (): void {
$phpVersion = currentHost()->get('php_version');
run('sudo -n nginx -t');
run('sudo -n systemctl reload nginx');
run('sudo -n systemctl restart php8.3-fpm');
run("sudo -n systemctl restart php{$phpVersion}-fpm");
});
after('deploy:update_code', 'deploy:write_revision_file');
@@ -41,6 +41,12 @@ class LicensingServiceProvider extends ServiceProvider
*/
public function boot() : void
{
\Illuminate\Support\Facades\Blade::anonymousComponentPath(
base_path('modules/Licensing/Resources/views/components/layouts'),
'layouts'
);
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
@@ -41,7 +41,7 @@ final class LicenseDeliveryService
'downloadUrl' => route('release'),
];
Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
Mail::send(['html' => 'licensing::emails.license-key', 'text' => 'licensing::emails.license-key-text'], $data, function ($message) use ($recipient): void {
$message->to($recipient)->subject('Your SonarDesk licence key');
});
@@ -0,0 +1,86 @@
@extends('layouts.app', ['title' => 'Stylist — SonarDesk Administration'])
@section('content')
<div class="mb-6">
<p class="text-sm font-medium text-gray-500">Administration</p>
<h1 class="text-3xl font-bold">Stylist</h1>
<p class="mt-2 text-gray-600">
Install, configure, validate and export self-contained themes.
</p>
</div>
@if (session('status'))
<div class="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800">{{ session('status') }}</div>
@endif
@if ($errors->any())
<div class="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-red-800">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('stylist.install') }}" enctype="multipart/form-data" class="mb-8 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
@csrf
<label class="min-w-64 flex-1"><span class="mb-2 block text-sm font-medium text-slate-700">Install theme ZIP</span><input type="file" name="theme" accept=".zip" required class="block w-full rounded-lg border border-slate-300 p-2"></label>
<button class="rounded-lg bg-slate-900 px-4 py-2.5 font-semibold text-white">Install theme</button>
</form>
<div class="grid gap-6 xl:grid-cols-2">
@foreach ($themes as $item)
@php($saved = $settingsService->get($item))
@php($variant = $settingsService->variant($item))
@php($issues = $accessibility->validate($item))
@php($appearance = $settingsService->appearance($item))
@php($accent = $settingsService->accent($item))
<article class="overflow-hidden rounded-2xl border {{ (($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme)) ? 'border-blue-400 ring-2 ring-blue-100' : 'border-slate-200' }} bg-white shadow-sm">
<div class="aspect-[16/7] bg-slate-100">
@if ($item->preview() && $item->has($item->preview()))
<img src="{{ $urls->forTheme($item, $item->preview()) }}" alt="{{ $item->name() }} theme preview" class="h-full w-full object-cover">
@else
<div class="flex h-full items-center justify-center text-sm text-slate-500">Add preview.webp to this theme</div>
@endif
</div>
<div class="p-5">
<div class="flex items-start justify-between gap-4"><div><h2 class="text-xl font-bold text-slate-950">{{ $item->name() }}</h2><p class="text-sm text-slate-500">v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif</p></div>@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))<span class="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">Active {{ ucfirst($item->type()) }}</span>@endif</div>
@if($item->description())<p class="mt-3 text-sm text-slate-600">{{ $item->description() }}</p>@endif
@if($item->parent())<p class="mt-2 text-xs font-medium text-violet-700">Child of {{ $item->parent() }}</p>@endif
<div class="mt-4 flex flex-wrap gap-2">
@if(in_array($item->type(), ['frontend', 'backend'], true))
<form method="POST" action="{{ route('stylist.activate', $item->key()) }}">@csrf<button class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white">Activate {{ ucfirst($item->type()) }}</button></form>
<a href="{{ route('stylist.preview', $item->key()) }}" target="_blank" rel="noopener" class="rounded-lg border border-blue-300 px-3 py-2 text-sm font-medium text-blue-700">Preview</a>
@endif
<a href="{{ route('stylist.export', $item->key()) }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Export ZIP</a>
<form method="POST" action="{{ route('stylist.optimize', $item->key()) }}">@csrf<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Optimise assets</button></form>
@if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__'))
<form method="POST" action="{{ route('stylist.uninstall', $item->key()) }}" onsubmit="return confirm('Uninstall this theme?')">@csrf @method('DELETE')<button class="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700">Uninstall</button></form>
@endif
</div>
<details class="mt-5 rounded-xl border border-slate-200 p-4">
<summary class="cursor-pointer font-semibold">Theme settings</summary>
<form method="POST" action="{{ route('stylist.settings', $item->key()) }}" class="mt-4 space-y-4">@csrf @method('PUT')
@if($item->variants())
<label class="block"><span class="mb-1 block text-sm font-medium">Variant</span><select name="variant" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->variants()) as $name)<option value="{{ $name }}" @selected($variant === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
@endif
@if($item->supports('dark_mode') || $item->appearances())
<label class="block"><span class="mb-1 block text-sm font-medium">Appearance</span><select name="appearance" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)<option value="{{ $name }}" @selected($appearance === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
@endif
@if($item->accents())
<div><span class="mb-2 block text-sm font-medium">Accent colour</span><div class="flex flex-wrap gap-2">@foreach($item->accents() as $name => $definition)<label class="cursor-pointer"><input type="radio" name="accent" value="{{ $name }}" class="peer sr-only" @checked($accent === $name)><span class="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-100"><span class="h-4 w-4 rounded-full border border-black/10" style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}"></span>{{ data_get($definition, 'label', ucfirst($name)) }}</span></label>@endforeach</div></div>
@endif
@foreach($item->settingsSchema() as $key => $definition)
@php($definition = is_array($definition) ? $definition : ['default' => $definition])
<label class="block"><span class="mb-1 block text-sm font-medium">{{ $definition['label'] ?? str($key)->headline() }}</span>
@switch($definition['type'] ?? 'text')
@case('color')<input type="color" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '#000000') }}" class="h-10 w-full rounded-lg border-slate-300">@break
@case('textarea')<textarea name="settings[{{ $key }}]" rows="4" class="w-full rounded-lg border-slate-300">{{ $saved[$key] ?? ($definition['default'] ?? '') }}</textarea>@break
@case('checkbox')<input type="hidden" name="settings[{{ $key }}]" value="0"><label class="inline-flex items-center gap-2"><input type="checkbox" name="settings[{{ $key }}]" value="1" @checked((bool)($saved[$key] ?? ($definition['default'] ?? false))) class="rounded border-slate-300"><span>Enabled</span></label>@break
@default<input type="{{ ($definition['type'] ?? 'text') === 'url' ? 'url' : 'text' }}" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '') }}" class="w-full rounded-lg border-slate-300">
@endswitch
</label>
@endforeach
<button class="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white">Save settings</button>
</form>
</details>
<div class="mt-4 text-sm {{ collect($issues)->contains('severity', 'error') ? 'text-red-700' : 'text-emerald-700' }}">Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}</div>
</div>
</article>
@endforeach
</div>
@endsection
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'default_theme' => env('STYLIST_DEFAULT_THEME', 'sonardesk-admin'),
'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'sonardesk-admin'),
'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'sonardesk'),
/*
| Themes are application resources, never module resources.
| Every theme is self-contained and owns its views and assets.
*/
'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')),
'tenant_theme_column' => 'theme_key',
'tenant_brand_columns' => [
'brand_primary' => 'brand-primary',
'brand_secondary' => 'brand-secondary',
'brand_accent' => 'brand-accent',
],
'fallback_variables' => [
'brand-primary' => '#2563eb',
'brand-secondary' => '#0f172a',
'brand-accent' => '#22c55e',
'surface' => '#ffffff',
'surface-muted' => '#f8fafc',
'text-primary' => '#0f172a',
],
];
@@ -0,0 +1,64 @@
<?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,40 @@
<?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,28 @@
<?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,34 @@
<?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,38 @@
<?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,108 @@
<?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];
}
}
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
if (! Schema::hasColumn('companies', 'theme_key')) {
$table->string('theme_key')->default('default')->after('timezone');
}
if (! Schema::hasColumn('companies', 'brand_primary')) {
$table->string('brand_primary')->nullable()->after('theme_key');
}
if (! Schema::hasColumn('companies', 'brand_secondary')) {
$table->string('brand_secondary')->nullable()->after('brand_primary');
}
if (! Schema::hasColumn('companies', 'brand_accent')) {
$table->string('brand_accent')->nullable()->after('brand_secondary');
}
if (! Schema::hasColumn('companies', 'logo_path')) {
$table->string('logo_path')->nullable()->after('brand_accent');
}
if (! Schema::hasColumn('companies', 'theme_settings')) {
$table->json('theme_settings')->nullable()->after('logo_path');
}
});
}
public function down(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) {
if (Schema::hasColumn('companies', $column)) {
$table->dropColumn($column);
}
}
});
}
};
@@ -0,0 +1,24 @@
<?php
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');
}
};
@@ -0,0 +1,147 @@
<?php
namespace Modules\Stylist\Diagnostics;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeInspector
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
) {}
/** @return array<int, array<string, mixed>> */
public function themes(): array
{
return $this->loader->all()->map(function (Theme $theme): array {
return [
'key' => $theme->key(),
'name' => $theme->name(),
'parent' => $theme->parent(),
'path' => $theme->path(),
'views' => count($this->viewFiles($theme)),
'entry_view' => $theme->entryView(),
];
})->values()->all();
}
/** @return array<string, array<int, array{theme:string,path:string}>> */
public function duplicateViews(): array
{
$ownership = [];
foreach ($this->loader->all() as $theme) {
foreach ($this->viewFiles($theme) as $relative => $absolute) {
$ownership[$relative][] = [
'theme' => $theme->key(),
'path' => $absolute,
];
}
}
return array_filter($ownership, fn (array $owners): bool => count($owners) > 1);
}
/** @return array<int, array{severity:string,theme:string,message:string}> */
public function issues(): array
{
$issues = [];
$themes = $this->loader->all();
foreach ($themes as $theme) {
$parent = $theme->parent();
if ($parent !== null && ! $themes->has($parent)) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => "Parent theme [{$parent}] is not installed.",
];
}
if ($this->hasInheritanceCycle($theme, $themes->all())) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => 'Theme inheritance contains a cycle.',
];
}
$entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $this->files->exists($entry) && $parent === null) {
$issues[] = [
'severity' => 'warning',
'theme' => $theme->key(),
'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.",
];
}
}
foreach ($this->duplicateViews() as $view => $owners) {
$issues[] = [
'severity' => 'error',
'theme' => implode(', ', array_column($owners, 'theme')),
'message' => "View [{$view}] has multiple owners.",
];
}
$default = (string) config('stylist.default_theme');
if ($default !== '' && ! $themes->has($default)) {
$issues[] = [
'severity' => 'warning',
'theme' => $default,
'message' => 'Configured default theme is not installed.',
];
}
return $issues;
}
/** @return array<string, string> */
private function viewFiles(Theme $theme): array
{
$root = $theme->path().'/views';
if (! $this->files->isDirectory($root)) {
return [];
}
$views = [];
foreach ($this->files->allFiles($root) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$absolute = str_replace('\\', '/', $file->getPathname());
$relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/');
$views[$relative] = $absolute;
}
ksort($views);
return $views;
}
/** @param array<string, Theme> $themes */
private function hasInheritanceCycle(Theme $theme, array $themes): bool
{
$seen = [];
$key = $theme->key();
while (isset($themes[$key])) {
if (isset($seen[$key])) {
return true;
}
$seen[$key] = true;
$key = $themes[$key]->parent() ?? '';
if ($key === '') {
return false;
}
}
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class StylistFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist';
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class ThemeFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist.html';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?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;
use Modules\Stylist\View\ThemeViewResolver;
if (! function_exists('stylist')) {
function stylist(): Stylist
{
return app(Stylist::class);
}
}
if (! function_exists('stylist_html')) {
function stylist_html(): ThemeHtmlBuilder
{
return app(ThemeHtmlBuilder::class);
}
}
if (! function_exists('stylist_view')) {
function stylist_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->make($view, $data, $mergeData);
}
}
if (! function_exists('stylist_theme_view')) {
function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData);
}
}
if (! function_exists('stylist_frontend_view')) {
function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('stylist_backend_view')) {
function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('theme_asset')) {
function theme_asset(string $path): string
{
return app(UrlGenerator::class)->asset($path);
}
}
if (! function_exists('theme_switcher')) {
function theme_switcher(): HtmlString
{
return stylist_html()->switcher();
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
final class ThemeHtmlBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $url) {}
public function stylesheet(?string $path = null): HtmlString
{
$path ??= $this->stylist->current()->stylesheet();
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('');
}
return new HtmlString('<script src="'.e($this->url->asset($path)).'" defer></script>');
}
public function variables(): HtmlString
{
$theme = $this->stylist->current();
$default = $this->declarations($this->stylist->variables());
$rules = [':root{'.$default.'}'];
foreach ($theme->appearances() as $name => $definition) {
$vars = (array) data_get($definition, 'variables', []);
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 ($theme->appearances() === [] && $theme->accents() === []) {
return new HtmlString('<style>'.implode('', $rules).'</style>');
}
$appearance = json_encode($this->stylist->appearance(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$accent = json_encode($this->stylist->accent(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$themeKey = json_encode($theme->key(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$hasAppearances = $theme->appearances() !== [] ? 'true' : 'false';
$hasAccents = $theme->accents() !== [] ? 'true' : 'false';
$script = <<<JS
<script>(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.theme=dark?'dark':'light';d.dataset.appearance=a;d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})();</script>
JS;
return new HtmlString('<style>'.implode('', $rules).'</style>'.$script);
}
public function switcher(): HtmlString
{
$theme = $this->stylist->current();
$appearances = array_keys($theme->appearances());
$accents = array_keys($theme->accents());
if ($appearances === [] && $accents === []) {
return new HtmlString('');
}
$appearanceOptions = collect($appearances)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
$accentOptions = collect($accents)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
$appearanceSelect = $appearances ? '<label>Appearance <select data-stylist-appearance>'.$appearanceOptions.'</select></label>' : '';
$accentSelect = $accents ? '<label>Accent <select data-stylist-accent>'.$accentOptions.'</select></label>' : '';
return new HtmlString('<div data-stylist-switcher class="stylist-theme-switcher">'.$appearanceSelect.$accentSelect.'</div><script>document.addEventListener("stylist:ready",function(){document.querySelectorAll("[data-stylist-switcher]").forEach(function(root){var a=root.querySelector("[data-stylist-appearance]"),c=root.querySelector("[data-stylist-accent]");if(a){a.value=StylistTheme.appearance();a.addEventListener("change",function(){StylistTheme.setAppearance(a.value)})}if(c){c.value=StylistTheme.accent();c.addEventListener("change",function(){StylistTheme.setAccent(c.value)})}})});</script>');
}
private function declarations(array $variables): string
{
return collect($variables)
->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value)))
->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';')
->implode('');
}
private function selector(string $value): string
{
return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default';
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Stylist;
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
{
$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));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Routing\Controller;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
use Modules\Stylist\Services\SiteSettingsRepository;
final class StylistController extends Controller
{
public function __invoke(Stylist $stylist, ThemeSettings $settings, ThemeAccessibilityValidator $accessibility, UrlGenerator $urls, SiteSettingsRepository $siteSettings)
{
return view('stylist::index', [
'theme' => $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(),
'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls,
'activeFrontendTheme' => $siteSettings->get('frontend_theme', config('stylist.default_frontend_theme', 'sonardesk'), 'site'),
'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'sonardesk-admin'), 'site'),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Modules\Stylist\Theme\Loader;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ThemeAssetController extends Controller
{
private const ALLOWED_ROOTS = ['css', 'js', 'images', 'fonts', 'icons', 'assets'];
private const ALLOWED_EXTENSIONS = [
'css', 'js', 'mjs', 'map', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif',
'ico', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'json', 'xml', 'txt', 'webmanifest',
];
public function __invoke(string $theme, string $path, Loader $loader): BinaryFileResponse|Response
{
abort_if(str_contains($path, "\0"), 404);
$normalised = str_replace('\\', '/', ltrim($path, '/'));
$root = explode('/', $normalised, 2)[0] ?? '';
$extension = strtolower(pathinfo($normalised, PATHINFO_EXTENSION));
abort_unless(in_array($root, self::ALLOWED_ROOTS, true), 404);
abort_unless(in_array($extension, self::ALLOWED_EXTENSIONS, true), 404);
$loadedTheme = $loader->find($theme);
$themeRoot = realpath($loadedTheme->path());
abort_unless($themeRoot !== false, 404);
$asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised);
abort_unless(
$asset !== false
&& str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR)
&& is_file($asset),
404
);
$mime = match ($extension) {
'css' => 'text/css; charset=UTF-8',
'js', 'mjs' => 'application/javascript; charset=UTF-8',
'svg' => 'image/svg+xml',
'json', 'map', 'webmanifest' => 'application/json',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'otf' => 'font/otf',
default => mime_content_type($asset) ?: 'application/octet-stream',
};
return response()->file($asset, [
'Content-Type' => $mime,
'Cache-Control' => app()->isProduction()
? 'public, max-age=31536000, immutable'
: 'no-cache, private',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'",
]);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Stylist\Services\SiteSettingsRepository;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeOptimizer;
use Modules\Stylist\Services\ThemePackageManager;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\View\ThemeViewResolver;
final class ThemeManagementController extends Controller
{
public function activate(string $theme, Loader $loader, SiteSettingsRepository $settings)
{
$item = $loader->find($theme);
abort_unless(in_array($item->type(), ['frontend', 'backend'], true), 422, 'Only frontend and backend themes can be activated.');
$settings->put($item->type().'_theme', $item->key(), 'site', 'string', true);
return back()->with('status', $item->name().' is now the active '.$item->type().' theme.');
}
public function preview(string $theme, Loader $loader, ThemeViewResolver $views)
{
$item = $loader->find($theme);
abort_unless(
in_array($item->type(), ['frontend', 'backend'], true),
422,
'Only frontend and backend themes can be previewed.'
);
return view('stylist::preview', [
'themePreview' => true,
'previewTheme' => $item,
]);
}
public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings)
{
$item = $loader->find($theme);
$settings->save(
$item,
(array) $request->input('settings', []),
(string) $request->input('variant', 'light'),
$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']]);
$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);
abort_if(in_array($item->key(), ['sonardesk', 'sonardesk-admin'], true), 403, 'Bundled themes cannot be removed.');
$settings = app(SiteSettingsRepository::class);
abort_if($settings->get($item->type().'_theme', null, 'site') === $item->key(), 409, 'Deactivate this theme before uninstalling.');
$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);
return response()->download($path)->deleteFileAfterSend();
}
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
{
$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))]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Stylist\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Throwable;
final class ApplyActiveTheme
{
public function handle(Request $request, Closure $next)
{
$context = $request->is('admin', 'admin/*', 'stylist', 'stylist/*')
|| $request->routeIs('admin.*', 'stylist.*') ? 'backend' : 'frontend';
try {
$loader = app(Loader::class);
$theme = app(Stylist::class)->current($context);
if ($theme->type() !== $context) {
$theme = $loader->find($context === 'backend' ? 'sonardesk-admin' : 'sonardesk');
}
$paths = [];
$seen = [];
while ($theme && ! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$path = $theme->path().'/views';
if (is_dir($path)) {
$paths[] = $path;
}
$theme = $theme->parent() ? $loader->find($theme->parent()) : null;
}
$baseline = $loader->find($context === 'backend' ? 'sonardesk-admin' : 'sonardesk');
$paths[] = $baseline->path().'/views';
// Finder uses first match. Prepend in reverse order to preserve precedence.
foreach (array_reverse(array_unique($paths)) as $path) {
View::getFinder()->prependLocation($path);
}
} catch (Throwable $exception) {
report($exception);
// Original Laravel views remain available for recovery.
}
return $next($request);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Livewire;
use Livewire\Component;
final class ThemePreview extends Component
{
public array $stats = [
'companies' => 12,
'warehouses' => 34,
'users' => 156,
];
public function render()
{
return stylist_view('livewire.theme-preview', [
'stats' => $this->stats,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Stylist\Models;
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'];
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')
->group(module_path('Stylist', 'Routes/assets.php'));
Route::middleware('web')
->prefix('stylist')
->name('stylist.')
->group(module_path('Stylist', 'Routes/web.php'));
}
}
@@ -0,0 +1,220 @@
<?php
namespace Modules\Stylist\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Modules\Stylist\Console\CreateThemeCommand;
use Modules\Stylist\Console\FindDuplicateViewsCommand;
use Modules\Stylist\Console\InspectThemesCommand;
use Modules\Stylist\Console\PublishAssetsCommand;
use Modules\Stylist\Console\ThemeDoctorCommand;
use Modules\Stylist\Console\ValidateThemeCommand;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Html\ThemeSeoBuilder;
use Modules\Stylist\Support\FrontendNavigation;
use Modules\Stylist\Support\ModuleInfo;
use Modules\Stylist\Services\FrontendContent;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Services\SiteSettingsRepository;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
protected string $moduleNameLower = 'stylist';
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
$this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class));
$this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class));
$this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class));
}
public function boot(): void
{
\Illuminate\Support\Facades\View::addNamespace(
'sonardesk-base',
resource_path('views')
);
$this->registerViews();
$this->registerThemeViews();
$this->registerTranslations();
$this->registerPublishing();
$this->registerHelpers();
$this->registerBlade();
if ($this->app->runningInConsole()) {
$this->commands([
PublishAssetsCommand::class,
ValidateThemeCommand::class,
InspectThemesCommand::class,
FindDuplicateViewsCommand::class,
ThemeDoctorCommand::class,
CreateThemeCommand::class,
]);
}
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
View::composer('*', function ($view): void {
if (! $this->app->bound(Stylist::class)) {
return;
}
$view->with('activeTheme', $this->app->make(Stylist::class)->current());
});
View::composer('stylist-theme-saasland::*', function ($view): void {
$theme = $this->app->make(Loader::class)->find('saasland');
$settings = $this->app->make(ThemeSettings::class)->get($theme);
$siteSettings = $this->app->make(SiteSettingsRepository::class)->site();
$navigation = $this->app->make(FrontendNavigation::class);
$homepage = $this->app->make(FrontendContent::class)->homepage($settings);
$view->with([
'siteSettings' => $siteSettings,
'siteTitle' => $siteSettings['site_name'],
'siteDescription' => $siteSettings['site_description'],
'logoUrl' => $siteSettings['logo_url'] ?: null,
'facebookUrl' => $siteSettings['facebook_url'] ?: null,
'xUrl' => $siteSettings['x_url'] ?: null,
'linkedinUrl' => $siteSettings['linkedin_url'] ?: null,
'primaryNavigation' => $navigation->primary(),
'footerNavigation' => $navigation->footer(),
'themeSettings' => $settings,
...$homepage,
]);
});
}
private function registerViews(): void
{
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$viewPath = resource_path('views/modules/'.ModuleInfo::nameLower());
$this->publishes([$sourcePath => $viewPath], ['views', 'stylist-module-views']);
$this->loadViewsFrom([$viewPath, $sourcePath], ModuleInfo::nameLower());
}
private function registerThemeViews(): void
{
foreach ($this->themeRoots() as $root) {
if (! is_dir($root)) {
logger()->warning('Stylist theme root does not exist', [
'root' => $root,
]);
continue;
}
$themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: [];
foreach ($themeDirectories as $themeDirectory) {
$themeViewsPath = $themeDirectory.'/views';
if (! is_dir($themeViewsPath)) {
continue;
}
$themeKey = strtolower(basename($themeDirectory));
// Themes always live outside the module, normally in resources/themes.
// Register the canonical namespace and the legacy namespace as an alias
// to the same application-owned directory. The alias keeps existing
// routes and Blade templates working without reintroducing module themes.
$namespaces = [
"stylist-theme-{$themeKey}",
"stylist-module-theme-{$themeKey}",
];
$componentsPath = $themeViewsPath.'/components';
// The common theme is the application-wide owner of layouts,
// authentication, settings and shared components. Livewire 4
// resolves its default full-page layout through layouts::app,
// so point that compatibility namespace at the common theme.
if ($themeKey === 'common') {
$layoutsPath = $componentsPath.'/layouts';
if (is_dir($layoutsPath)) {
View::addNamespace('layouts', $layoutsPath);
Blade::anonymousComponentPath($layoutsPath, 'layouts');
}
View::addNamespace('common', $themeViewsPath);
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, 'common');
}
}
foreach ($namespaces as $namespace) {
View::addNamespace($namespace, $themeViewsPath);
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, $namespace);
}
}
}
}
}
private function normalisePath(string $path): string
{
return rtrim(
str_replace('\\', '/', realpath($path) ?: $path),
'/'
);
}
/** @return array<int, string> */
private function themeRoots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
private function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
$this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
}
private function registerPublishing(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
], ['config', 'stylist-config']);
}
private function registerHelpers(): void
{
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
require_once $filename;
}
}
private function registerBlade(): void
{
Blade::directive('themeVariables', fn () => '<?php echo stylist_html()->variables(); ?>');
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
}
}
+183
View File
@@ -0,0 +1,183 @@
# Stylist Module
Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules.
## Theme layout
Each theme is self-contained:
```text
resources/themes/{theme}/
├── theme.json
├── css/theme.css
└── views/
├── layouts/app.blade.php
├── components/page-hero.blade.php
└── livewire/theme-preview.blade.php
```
The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`.
## Rendering themed views
Use the helper from controllers, Blade components, and Livewire components:
```php
return stylist_view('layouts.app');
```
It checks views in this order:
```text
stylist-theme-{active-theme}::{view}
stylist-theme-{default-theme}::{view}
stylist::{view}
```
## Livewire
Keep the PHP component class shared and put the template in each theme:
```text
Livewire/ThemePreview.php
resources/themes/default/views/livewire/theme-preview.blade.php
resources/themes/mpba/views/livewire/theme-preview.blade.php
```
The component renders with:
```php
return stylist_view('livewire.theme-preview', ['stats' => $this->stats]);
```
## Tenant assignment
Add or use these fields on `companies`:
```text
theme_key
brand_primary
brand_secondary
brand_accent
logo_path
favicon_path
theme_settings
```
Then assign:
```php
$company->update(['theme_key' => 'mpba']);
```
## Publishing
```bash
php artisan vendor:publish --tag=stylist-config
# or
php artisan stylist:publish-assets
Themes are not published from or stored inside the module. Install them directly under `resources/themes`.
```
## Blade helpers
```blade
<html data-theme="{{ stylist()->current()->key() }}">
@themeVariables
@themeStylesheet
</html>
```
## Self-contained application themes
Application themes live in `resources/themes/{theme}` and own their runtime assets:
```text
resources/themes/example/
├── theme.json
├── css/theme.css
├── js/app.js
└── views/home.blade.php
```
Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs.
## Appearance and colour variants
Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `<head>` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page.
## Theme manifest compatibility
Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare:
```json
{
"manifest_version": 1,
"key": "design-gallery",
"name": "Design Gallery"
}
```
`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery.
Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist:
```json
{
"appearances": ["light", "dark", "system"],
"accents": ["blue", "neutral"],
"default_appearance": "system",
"default_accent": "neutral"
}
```
Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls.
Additional boolean capabilities may be declared explicitly:
```json
{
"capabilities": {
"marketplace": true
}
}
```
## Validate a theme
Validate an installed theme key, unpacked theme directory, or ZIP package:
```bash
php artisan stylist:validate design-gallery
php artisan stylist:validate resources/themes/design-gallery
php artisan stylist:validate ~/Downloads/design-gallery.zip
```
The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found.
## Theme inheritance and diagnostics
Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views:
```json
{
"name": "Dashboard",
"slug": "admin",
"extends": "common"
}
```
Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views.
Use these maintenance commands:
```bash
php artisan stylist:inspect
php artisan stylist:duplicates
php artisan stylist:doctor
php artisan stylist:create-theme customer --parent=common --name="Customer Portal"
```
`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task.
@@ -0,0 +1,5 @@
[data-theme] {
--brand-primary: var(--brand-primary, #2563eb);
--brand-secondary: var(--brand-secondary, #0f172a);
--brand-accent: var(--brand-accent, #22c55e);
}
@@ -0,0 +1 @@
import '../css/app.css';
@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Stylist',
];
@@ -0,0 +1,473 @@
@extends('layouts.app', ['title' => 'Stylist — SonarDesk Administration'])
@section('content')
<div class="stylist-admin">
<section class="portal-header stylist-header">
<div>
<span class="eyebrow">Administration / Appearance</span>
<h1>Stylist</h1>
<p class="lead compact">
Manage frontend and administration themes independently.
Install, configure, preview and export complete themes.
</p>
</div>
<a href="{{ route('admin.index') }}" class="btn btn-secondary">
Back to administration
</a>
</section>
@if(session('status'))
<div class="alert">{{ session('status') }}</div>
@endif
@if($errors->any())
<div class="error">{{ $errors->first() }}</div>
@endif
<section class="stylist-summary">
<div class="stylist-summary-card">
<span>Installed themes</span>
<strong>{{ count($themes) }}</strong>
<small>Available designs</small>
</div>
<div class="stylist-summary-card">
<span>Frontend theme</span>
<strong>{{ $activeFrontendTheme }}</strong>
<small>Public website</small>
</div>
<div class="stylist-summary-card">
<span>Backend theme</span>
<strong>{{ $activeBackendTheme }}</strong>
<small>Administration interface</small>
</div>
</section>
<section class="stylist-install-panel">
<div class="stylist-section-heading">
<div>
<span class="eyebrow">Theme library</span>
<h2>Install a theme</h2>
<p>Upload a self-contained theme package in ZIP format.</p>
</div>
</div>
<form method="POST"
action="{{ route('stylist.install') }}"
enctype="multipart/form-data"
class="stylist-install-form">
@csrf
<label class="stylist-upload-field">
<span>Theme package</span>
<input type="file"
name="theme"
accept=".zip"
required>
<small>Maximum upload size: 50 MB</small>
</label>
<button type="submit" class="btn btn-primary">
Install theme
</button>
</form>
</section>
<section class="stylist-library">
<div class="stylist-section-heading">
<div>
<span class="eyebrow">Appearance</span>
<h2>Installed themes</h2>
<p>Frontend and backend themes can be activated independently.</p>
</div>
</div>
<div class="stylist-theme-grid">
@foreach($themes as $item)
@php
$saved = $settingsService->get($item);
$variant = $settingsService->variant($item);
$appearance = $settingsService->appearance($item);
$accent = $settingsService->accent($item);
$issues = $accessibility->validate($item);
$isActive =
($item->type() === 'frontend' && $item->key() === $activeFrontendTheme)
|| ($item->type() === 'backend' && $item->key() === $activeBackendTheme);
$isBundled = in_array(
$item->key(),
['sonardesk', 'sonardesk-admin'],
true
);
$themeRoot = realpath(config('stylist.themes_path'));
$themePath = realpath($item->path());
$isInstalled = $themeRoot
&& $themePath
&& str_starts_with(
$themePath,
rtrim($themeRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
);
@endphp
<article class="stylist-theme-card {{ $isActive ? 'stylist-theme-active' : '' }}">
<div class="stylist-preview">
@if($item->preview() && $item->has($item->preview()))
<img
src="{{ $urls->forTheme($item, $item->preview()) }}"
alt="{{ $item->name() }} preview">
@else
<div class="stylist-preview-placeholder">
<span class="stylist-preview-symbol">◈</span>
<strong>{{ $item->name() }}</strong>
<small>No preview image available</small>
</div>
@endif
</div>
<div class="stylist-theme-body">
<div class="stylist-theme-heading">
<div>
<span class="stylist-theme-type">
{{ ucfirst($item->type()) }} theme
</span>
<h3>{{ $item->name() }}</h3>
<p class="stylist-version">
Version {{ $item->version() }}
@if($item->author())
· {{ $item->author() }}
@endif
</p>
</div>
@if($isActive)
<span class="status-chip success">
Active
</span>
@endif
</div>
@if($item->description())
<p class="stylist-description">
{{ $item->description() }}
</p>
@endif
@if($item->parent())
<p class="stylist-parent">
Based on {{ $item->parent() }}
</p>
@endif
<div class="stylist-actions">
@if(in_array($item->type(), ['frontend', 'backend'], true))
@unless($isActive)
<form method="POST"
action="{{ route('stylist.activate', $item->key()) }}">
@csrf
<button type="submit" class="btn btn-primary">
Activate {{ ucfirst($item->type()) }}
</button>
</form>
@endunless
<a class="btn btn-secondary"
href="{{ route('stylist.preview', $item->key()) }}"
target="_blank"
rel="noopener">
Preview
</a>
@endif
<a class="btn btn-secondary"
href="{{ route('stylist.export', $item->key()) }}">
Export ZIP
</a>
<form method="POST"
action="{{ route('stylist.optimize', $item->key()) }}">
@csrf
<button type="submit" class="btn btn-secondary">
Optimise assets
</button>
</form>
</div>
<details class="stylist-settings">
<summary>
<span>Theme configuration</span>
<span class="stylist-chevron">⌄</span>
</summary>
<form method="POST"
action="{{ route('stylist.settings', $item->key()) }}"
class="stylist-settings-form">
@csrf
@method('PUT')
@if($item->variants())
<label>
Variant
<select name="variant">
@foreach(array_keys($item->variants()) as $name)
<option value="{{ $name }}"
@selected($variant === $name)>
{{ ucfirst($name) }}
</option>
@endforeach
</select>
</label>
@endif
@if($item->supports('dark_mode') || $item->appearances())
<label>
Appearance
<select name="appearance">
@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)
<option value="{{ $name }}"
@selected($appearance === $name)>
{{ ucfirst($name) }}
</option>
@endforeach
</select>
</label>
@endif
@if($item->accents())
<fieldset class="stylist-accent-options">
<legend>Accent colour</legend>
@foreach($item->accents() as $name => $definition)
<label class="stylist-accent-option">
<input type="radio"
name="accent"
value="{{ $name }}"
@checked($accent === $name)>
<span class="stylist-colour-swatch"
style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}">
</span>
{{ data_get($definition, 'label', ucfirst($name)) }}
</label>
@endforeach
</fieldset>
@endif
@foreach($item->settingsSchema() as $key => $definition)
@php
$definition = is_array($definition)
? $definition
: ['default' => $definition];
$fieldType = $definition['type'] ?? 'text';
$fieldLabel = $definition['label'] ?? str($key)->headline();
$fieldValue = $saved[$key] ?? ($definition['default'] ?? '');
@endphp
<div class="stylist-setting-field">
<label for="stylist-{{ $item->key() }}-{{ $key }}">
{{ $fieldLabel }}
</label>
@switch($fieldType)
@case('color')
<input
id="stylist-{{ $item->key() }}-{{ $key }}"
type="color"
name="settings[{{ $key }}]"
value="{{ $fieldValue }}">
@break
@case('textarea')
<textarea
id="stylist-{{ $item->key() }}-{{ $key }}"
name="settings[{{ $key }}]"
rows="4">{{ $fieldValue }}</textarea>
@break
@case('checkbox')
<input type="hidden"
name="settings[{{ $key }}]"
value="0">
<label class="stylist-checkbox">
<input
id="stylist-{{ $item->key() }}-{{ $key }}"
type="checkbox"
name="settings[{{ $key }}]"
value="1"
@checked((bool)$fieldValue)>
Enabled
</label>
@break
@default
<input
id="stylist-{{ $item->key() }}-{{ $key }}"
type="{{ $fieldType === 'url' ? 'url' : 'text' }}"
name="settings[{{ $key }}]"
value="{{ $fieldValue }}">
@endswitch
</div>
@endforeach
<button type="submit" class="btn btn-primary">
Save configuration
</button>
</form>
</details>
<div class="stylist-theme-footer">
<span class="{{ collect($issues)->contains('severity', 'error') ? 'bad' : 'ok' }}">
Accessibility:
{{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}
</span>
@if($isInstalled && !$isBundled && !$isActive)
<form method="POST"
action="{{ route('stylist.uninstall', $item->key()) }}"
onsubmit="return confirm('Permanently uninstall this theme?')">
@csrf
@method('DELETE')
<button type="submit" class="stylist-remove">
Uninstall
</button>
</form>
@endif
</div>
</div>
</article>
@endforeach
</div>
</section>
</div>
<style>
.stylist-admin{max-width:1220px;margin:auto;padding-bottom:60px}
.stylist-header{margin-bottom:30px}
.stylist-header .lead{max-width:740px}
.stylist-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:28px}
.stylist-summary-card{background:#fff;border:1px solid #e4e7ec;border-radius:15px;padding:22px;box-shadow:0 2px 6px rgba(15,23,42,.04)}
.stylist-summary-card span,.stylist-summary-card small{display:block;color:#667085;font-size:12px}
.stylist-summary-card strong{display:block;margin:8px 0;font-size:24px;color:#101828;overflow-wrap:anywhere}
.stylist-install-panel{background:#fff;border:1px solid #e4e7ec;border-radius:16px;padding:26px;margin-bottom:35px;box-shadow:0 2px 8px rgba(15,23,42,.04)}
.stylist-section-heading{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:22px}
.stylist-section-heading h2{font-size:23px;margin:5px 0}
.stylist-section-heading p{margin:0;color:#667085}
.stylist-install-form{display:flex;gap:20px;align-items:end}
.stylist-upload-field{display:block;flex:1;font-size:13px;font-weight:750}
.stylist-upload-field span,.stylist-upload-field small{display:block}
.stylist-upload-field small{color:#667085;font-weight:400}
.stylist-upload-field input{margin:8px 0}
.stylist-install-form button{margin-bottom:25px}
.stylist-theme-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}
.stylist-theme-card{background:#fff;border:1px solid #e4e7ec;border-radius:18px;overflow:hidden;box-shadow:0 5px 22px rgba(15,23,42,.05);min-width:0}
.stylist-theme-active{border-color:#93c5fd;box-shadow:0 0 0 3px #dbeafe}
.stylist-preview{height:210px;background:#f1f5f9;border-bottom:1px solid #e4e7ec;overflow:hidden}
.stylist-preview img{width:100%;height:100%;object-fit:cover}
.stylist-preview-placeholder{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#101828,#1d3557);color:#fff;gap:5px}
.stylist-preview-symbol{font-size:40px;color:#93c5fd}
.stylist-preview-placeholder strong{font-size:18px}
.stylist-preview-placeholder small{font-size:12px;color:#94a3b8}
.stylist-theme-body{padding:25px}
.stylist-theme-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.stylist-theme-type{text-transform:uppercase;letter-spacing:.1em;color:#2563eb;font-size:10px;font-weight:850}
.stylist-theme-heading h3{font-size:22px;letter-spacing:-.025em;margin:5px 0}
.stylist-version{color:#667085;font-size:12px;margin:0}
.stylist-description{color:#475467;font-size:14px;line-height:1.6;margin:18px 0}
.stylist-parent{color:#7c3aed;font-size:12px}
.stylist-actions{display:flex;gap:9px;flex-wrap:wrap;margin:22px 0}
.stylist-actions form{margin:0}
.stylist-actions .btn{font-size:12px;padding:9px 12px}
.stylist-settings{border:1px solid #e4e7ec;border-radius:12px;margin-top:20px;overflow:hidden}
.stylist-settings summary{display:flex;justify-content:space-between;align-items:center;padding:15px 17px;cursor:pointer;font-weight:750;list-style:none}
.stylist-settings summary::-webkit-details-marker{display:none}
.stylist-settings[open] summary{border-bottom:1px solid #e4e7ec}
.stylist-chevron{font-size:20px;color:#667085}
.stylist-settings-form{padding:20px;display:grid;gap:14px}
.stylist-settings-form label{font-size:13px}
.stylist-settings-form input:not([type=radio]):not([type=checkbox]),.stylist-settings-form select,.stylist-settings-form textarea{width:100%;margin:6px 0 0}
.stylist-settings-form input[type=checkbox],.stylist-settings-form input[type=radio]{width:auto;margin:0}
.stylist-accent-options{border:1px solid #e4e7ec;border-radius:10px;padding:14px;display:flex;flex-wrap:wrap;gap:12px}
.stylist-accent-options legend{font-size:13px;font-weight:750}
.stylist-accent-option{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
.stylist-colour-swatch{width:17px;height:17px;border-radius:50%;border:1px solid rgba(0,0,0,.12)}
.stylist-checkbox{display:flex;align-items:center;gap:9px}
.stylist-theme-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid #eef2f6;padding-top:17px;margin-top:20px;font-size:12px}
.stylist-remove{background:transparent!important;color:#b42318!important;padding:5px 0!important;font-size:12px!important;box-shadow:none!important}
.stylist-remove:hover{text-decoration:underline}
@media(max-width:850px){.stylist-theme-grid{grid-template-columns:1fr}.stylist-summary{grid-template-columns:1fr}.stylist-install-form{flex-direction:column;align-items:stretch}.stylist-install-form button{margin:0}}
</style>
@endsection
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Stylist — Theme Management')</title>
<style>
:root {
color-scheme: light;
--stylist-bg: #f8fafc;
--stylist-surface: #ffffff;
--stylist-text: #0f172a;
--stylist-muted: #64748b;
--stylist-border: #e2e8f0;
--stylist-primary: #2563eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--stylist-bg);
color: var(--stylist-text);
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
line-height: 1.5;
}
.stylist-shell {
width: min(1600px, 100%);
margin: 0 auto;
padding: 32px;
}
.stylist-page-header {
margin-bottom: 28px;
}
.stylist-page-header h1 {
margin: 8px 0;
font-size: 32px;
font-weight: 750;
letter-spacing: -0.03em;
}
.stylist-page-header p {
margin: 0;
color: var(--stylist-muted);
}
.stylist-eyebrow {
color: var(--stylist-primary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
button, input, select, textarea {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: inherit;
}
@media (max-width: 768px) {
.stylist-shell {
padding: 20px 16px;
}
}
</style>
@stack('styles')
</head>
<body>
<main class="stylist-shell">
@yield('content')
</main>
@stack('scripts')
</body>
</html>
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $previewTheme->name() }} — Theme Preview</title>
<style>
:root {
color-scheme: light;
--background: #f3f6fa;
--surface: #fff;
--text: #17243b;
--muted: #64748b;
--accent: #2563eb;
--border: #dce4ee;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a { color: inherit; text-decoration: none; }
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 14px 28px;
background: #142238;
color: white;
}
.toolbar strong { font-size: 14px; }
.toolbar small { display: block; color: #aebbd0; margin-top: 4px; }
.toolbar a {
background: white;
color: #142238;
border-radius: 7px;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
}
.site-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22px 6%;
background: white;
border-bottom: 1px solid var(--border);
}
.site-nav strong { font-size: 21px; }
.site-nav span { color: var(--muted); font-size: 13px; }
.container { max-width: 1200px; margin: 0 auto; padding: 55px 25px; }
.eyebrow {
text-transform: uppercase;
letter-spacing: 2px;
font-size: 12px;
font-weight: 700;
color: var(--accent);
}
h1 { font-size: clamp(32px, 5vw, 54px); margin: 15px 0; }
.lead { color: var(--muted); font-size: 18px; max-width: 680px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 20px;
margin-top: 35px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
box-shadow: 0 8px 30px rgba(15,23,42,.035);
}
.card h3 { margin-top: 0; }
.card p { color: var(--muted); line-height: 1.6; }
.button {
display: inline-block;
padding: 13px 20px;
border-radius: 8px;
background: var(--accent);
color: white;
font-weight: 600;
margin-top: 15px;
}
.footer {
text-align: center;
padding: 35px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 650px) {
.toolbar, .site-nav { flex-wrap: wrap; }
}
</style>
</head>
<body>
<header class="toolbar">
<div>
<strong>Stylist — Theme Preview</strong>
<small>
{{ $previewTheme->name() }}
· Version {{ $previewTheme->version() }}
· {{ ucfirst($previewTheme->type()) }}
</small>
</div>
<a href="{{ route('stylist.index') }}">← Back to Stylist</a>
</header>
<nav class="site-nav">
<strong>SonarDesk</strong>
<span>
{{ $previewTheme->type() === 'backend'
? 'Administration interface'
: 'Public website' }}
</span>
</nav>
<main class="container">
@if($previewTheme->type() === 'backend')
<span class="eyebrow">Administration / Dashboard</span>
<h1>Administration overview</h1>
<p class="lead">
Preview the administration interface using representative data.
</p>
<div class="grid">
<div class="card"><h3>128</h3><p>Licences</p></div>
<div class="card"><h3>116</h3><p>Active licences</p></div>
<div class="card"><h3>184</h3><p>Installations</p></div>
<div class="card"><h3>92</h3><p>Portal users</p></div>
</div>
<div class="grid">
<div class="card">
<h3>Release management</h3>
<p>Manage published software releases.</p>
<span class="button">Manage releases</span>
</div>
<div class="card">
<h3>Theme management</h3>
<p>Configure the appearance of SonarDesk.</p>
<span class="button">Manage themes</span>
</div>
</div>
@else
<span class="eyebrow">SonarDesk for macOS</span>
<h1>Code quality at a glance.</h1>
<p class="lead">
A native macOS dashboard for monitoring SonarQube projects,
quality gates, vulnerabilities and technical debt.
</p>
<span class="button">Explore SonarDesk</span>
<div class="grid">
<div class="card">
<h3>Quality gates</h3>
<p>Monitor project quality and identify failing checks.</p>
</div>
<div class="card">
<h3>Issues</h3>
<p>Review bugs, vulnerabilities and code smells.</p>
</div>
<div class="card">
<h3>Reports</h3>
<p>Export project reports for further analysis.</p>
</div>
</div>
@endif
</main>
<footer class="footer">
Theme preview only — no changes have been applied.
</footer>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\ThemeAssetController;
/*
| Public theme assets
| --------------------------------------------------------------------------
| Themes are stored beneath resources/themes, which is not web-accessible.
| This route safely streams allow-listed assets through Laravel at the URL
| expected by generated themes: /themes/{theme}/{path}.
*/
Route::get('/themes/{theme}/{path}', ThemeAssetController::class)
->where('theme', '[A-Za-z0-9_-]+')
->where('path', '.*')
->name('stylist.theme.asset');
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Middleware\AdminOnly;
use Modules\Stylist\Http\Controllers\StylistController;
use Modules\Stylist\Http\Controllers\ThemeManagementController;
Route::middleware(['auth', 'verified', AdminOnly::class])->group(function (): void {
Route::get('/', StylistController::class)->name('index');
Route::post('/install', [ThemeManagementController::class, 'install'])->name('install');
Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings');
Route::post('/{theme}/activate', [ThemeManagementController::class, 'activate'])->name('activate');
Route::get('/{theme}/preview', [ThemeManagementController::class, 'preview'])->name('preview');
Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall');
Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export');
Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize');
Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility');
});
+19
View File
@@ -0,0 +1,19 @@
# SonarDesk — Stylist theme integration
Stylist is the primary resolver for ordinary frontend and backend Laravel views. The active theme, its parents, the bundled baseline and finally `resources/views` are searched in that order.
- Frontend: `resources/themes/sonardesk/views`.
- Backend: `resources/themes/sonardesk-admin/views`.
- The bundled themes preserve existing markup, CSS, JavaScript, routes and business logic.
- `/admin` and `/stylist` use backend; other web pages (including authenticated customer pages) use frontend.
- AdminOnly middleware protects Stylist operations.
- Bundled and active themes cannot be uninstalled.
- Keep `resources/views`: email templates, fallback and compatibility views are required.
- New theme ZIPs must include all needed templates, layouts, assets and a valid theme.json. Missing views fall back to baseline.
- The middleware does not change asset URLs. Replacement themes must supply their own asset references and use the existing Stylist asset serving facilities.
## Local validation
Run `php artisan optimize:clear`, `php artisan migrate`, `php artisan stylist:doctor` (if registered) and `php artisan view:cache` using your configured PHP binary. Test public pages, authentication, checkout, admin, Stylist, and transactional email before deployment.
This package has not been executed against your ServBay environment. Do not deploy to production without smoke testing.
@@ -0,0 +1,102 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Blog\Models\Blog;
use Modules\Faq\Models\Faq;
use Modules\News\Models\News;
use Modules\Products\Models\Product;
use Modules\Team\Models\TeamMember;
use Modules\Testimonials\Models\Testimonial;
use Throwable;
final class FrontendContent
{
/** @return array<string, mixed> */
public function homepage(array $settings): array
{
// Always provide source collections. Visibility is owned by page placements
// in the composable content system; the legacy theme still applies its
// existing show_* settings while no composition has been installed.
return [
'pricingPlans' => $this->pricingPlans(),
'testimonials' => $this->testimonials(),
'faqs' => $this->faqs(),
'teamMembers' => $this->team(),
'latestBlogPosts' => $this->blogPosts(),
'latestNewsArticles' => $this->newsArticles(),
];
}
private function pricingPlans(): Collection
{
return $this->query('products', fn () => Product::query()
->where('is_active', true)
->where('product_type', 'plan')
->orderBy('sort_order')
->orderBy('id')
->limit(6)
->get());
}
private function testimonials(): Collection
{
return $this->query('testimonials', fn () => Testimonial::query()
->where('enabled', true)
->latest('id')
->limit(6)
->get());
}
private function faqs(): Collection
{
return $this->query('faqs', fn () => Faq::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(12)
->get());
}
private function team(): Collection
{
return $this->query('team_members', fn () => TeamMember::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(8)
->get());
}
private function blogPosts(): Collection
{
return $this->query('blog_posts', fn () => Blog::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function newsArticles(): Collection
{
return $this->query('news_articles', fn () => News::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function query(string $table, callable $callback): Collection
{
try {
return Schema::hasTable($table) ? $callback() : collect();
} catch (Throwable) {
return collect();
}
}
private function enabled(array $settings, string $key, bool $default): bool
{
return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Stylist\Services;
use App\Models\SiteSetting;
final class SiteSettingsRepository
{
/**
* Retrieve a SonarDesk site setting.
*
* The scope argument is retained for compatibility with Stylist.
*/
public function get(
string $key,
mixed $default = null,
string $scope = 'site'
): mixed {
$this->validateScope($scope);
$value = SiteSetting::valueFor($key);
return $value ?? $default;
}
/**
* Persist a SonarDesk site setting.
*
* The type and public arguments are retained for compatibility
* with Stylist's existing theme management API.
*/
public function put(
string $key,
mixed $value,
string $scope = 'site',
string $type = 'string',
bool $public = true
): void {
$this->validateScope($scope);
SiteSetting::put(
$key,
$value === null ? null : (string) $value
);
}
/**
* Return all site settings as an associative array.
*/
public function site(): array
{
return SiteSetting::query()
->pluck('value', 'key')
->all();
}
private function validateScope(string $scope): void
{
if ($scope !== 'site') {
throw new \InvalidArgumentException(
"Unsupported settings scope: {$scope}"
);
}
}
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Support\Facades\Auth;
use Modules\Stylist\Services\SiteSettingsRepository;
final class TenantThemeResolver
{
public function __construct(private readonly SiteSettingsRepository $settings) {}
public function resolve(?string $context = null): string
{
$context ??= $this->context();
$column = config('stylist.tenant_theme_column', 'theme_key');
$user = Auth::user();
$company = $user?->company ?? null;
if ($company && filled($company->{$column} ?? null)) {
return (string) $company->{$column};
}
if (function_exists('tenant')) {
$tenant = tenant();
if ($tenant && filled($tenant->{$column} ?? null)) {
return (string) $tenant->{$column};
}
}
$settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme';
$default = $context === 'frontend'
? config('stylist.default_frontend_theme', 'sonardesk')
: config('stylist.default_backend_theme', config('stylist.default_theme', 'sonardesk-admin'));
return (string) $this->settings->get($settingKey, $default, 'site');
}
public function context(): string
{
if (app()->runningInConsole()) {
return 'backend';
}
$request = request();
return $request->is('admin', 'admin/*', 'stylist', 'stylist/*')
|| $request->routeIs('admin.*', 'stylist.*')
? 'backend'
: 'frontend';
}
public function variables(): array
{
$variables = config('stylist.fallback_variables', []);
$company = Auth::user()?->company ?? null;
foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) {
if ($company && filled($company->{$column} ?? null)) {
$variables[$variable] = (string) $company->{$column};
}
}
return $variables;
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeAccessibilityValidator
{
public function __construct(private readonly Filesystem $files) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
foreach ($this->files->allFiles($theme->path().'/views') as $file) {
$content = $file->getContents();
$relative = $file->getRelativePathname();
if (preg_match_all('/<img\b(?![^>]*\balt=)[^>]*>/i', $content, $matches)) {
$issues[] = ['severity' => 'error', 'message' => count($matches[0]).' image(s) missing alt text.', 'file' => $relative];
}
if (preg_match('/<html\b(?![^>]*\blang=)/i', $content)) {
$issues[] = ['severity' => 'warning', 'message' => 'HTML element is missing a lang attribute.', 'file' => $relative];
}
if (preg_match_all('/<button\b[^>]*>\s*(?:<[^>]+>\s*)*<\/button>/is', $content, $matches)) {
$issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative];
}
}
return $issues;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeOptimizer
{
public function __construct(private readonly Filesystem $files) {}
public function optimize(Theme $theme): array
{
$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;
}
foreach ($this->files->files($directory) as $file) {
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;
$content = preg_replace('/\s*([{}:;,>])\s*/', '$1', $content) ?? $content;
$target = $file->getPath().'/'.$file->getBasename('.'.$type).'.min.'.$type;
$this->files->put($target, trim($content));
$result[$type]++;
}
}
if (function_exists('imagewebp')) {
foreach (['images', 'icons'] as $folder) {
$directory = $theme->path().'/'.$folder;
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;
}
$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']++;
}
}
}
}
return $result;
}
}
@@ -0,0 +1,101 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
use Modules\Stylist\Services\SiteSettingsRepository;
use RuntimeException;
use ZipArchive;
final class ThemePackageManager
{
public function __construct(private readonly Filesystem $files, private readonly Loader $loader, private readonly ThemeValidator $validator, private readonly SiteSettingsRepository $settings) {}
public function install(UploadedFile $upload): Theme
{
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->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.');
}
$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.');
}
$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(' '));
}
$target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
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());
}
public function uninstall(Theme $theme): void
{
if (in_array($theme->key(), ['sonardesk', 'sonardesk-admin'], true)) {
throw new RuntimeException('Bundled SonarDesk themes cannot be uninstalled.');
}
$active = $theme->type() === 'frontend'
? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site')
: $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site');
if ($active === $theme->key()) {
throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.');
}
$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();
}
public function export(Theme $theme): string
{
$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;
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Database\QueryException;
use Modules\Stylist\Models\ThemeSetting;
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
{
$stored = $this->stored($theme);
$defaults = collect($theme->settingsSchema())
->mapWithKeys(fn ($definition, $key) => [$key => is_array($definition) ? ($definition['default'] ?? null) : $definition])
->all();
return array_replace($defaults, array_diff_key((array) ($stored?->settings ?? []), array_flip([self::APPEARANCE_KEY, self::ACCENT_KEY])));
}
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');
}
public function appearance(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->appearances());
if ($allowed === []) {
return '';
}
$appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
}
public function accent(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->accents());
if ($allowed === []) {
return '';
}
$accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
}
public function save(Theme $theme, array $settings, string $variant, ?string $appearance = null, ?string $accent = null): ThemeSetting
{
$settings = array_intersect_key($settings, array_flip(array_keys($theme->settingsSchema())));
$existing = (array) ($this->stored($theme)?->settings ?? []);
$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());
}
$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());
}
$settings[self::ACCENT_KEY] = $accent;
}
if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) {
$variant = array_key_first($theme->variants());
}
return ThemeSetting::query()->updateOrCreate(
['theme_key' => $theme->key()],
['settings' => $settings, 'variant' => $variant]
);
}
private function stored(Theme $theme): ?ThemeSetting
{
try {
return ThemeSetting::query()->where('theme_key', $theme->key())->first();
} catch (QueryException) {
return null;
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeValidator
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
private readonly ThemeAccessibilityValidator $accessibility,
) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validatePath(string $path): array
{
$theme = $this->loader->fromPath($path);
return $this->validate($theme);
}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
$entryView = 'views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $theme->has($entryView)) {
$issues[] = [
'severity' => 'error',
'message' => "Entry view [{$theme->entryView()}] was not found.",
'file' => $entryView,
];
}
foreach (['stylesheet' => $theme->stylesheet(), 'script' => $theme->script()] as $label => $relativePath) {
if ($relativePath !== '' && ! $theme->has($relativePath)) {
$issues[] = [
'severity' => 'error',
'message' => ucfirst($label)." file [{$relativePath}] was not found.",
'file' => $relativePath,
];
}
}
$preview = $theme->preview();
if ($preview !== null && ! $theme->has($preview)) {
$issues[] = [
'severity' => 'warning',
'message' => "Preview file [{$preview}] was not found.",
'file' => $preview,
];
}
foreach ((array) ($theme->meta()['assets'] ?? []) as $asset) {
if (! is_string($asset) || $asset === '') {
continue;
}
if (! $theme->has($asset)) {
$issues[] = [
'severity' => 'warning',
'message' => "Declared asset [{$asset}] was not found.",
'file' => $asset,
];
}
}
return array_merge($issues, $this->accessibility->validate($theme));
}
}
@@ -0,0 +1,76 @@
<?php
namespace Modules\Stylist\Support;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Modules\Menus\Models\Menu;
use Throwable;
final class FrontendNavigation
{
/** @return array<int, array<string, mixed>> */
public function primary(): array
{
return $this->location('primary', $this->fallbackPrimary());
}
/** @return array<int, array<string, mixed>> */
public function footer(): array
{
return $this->location('footer', $this->fallbackPrimary());
}
/** @param array<int, array<string, mixed>> $fallback
* @return array<int, array<string, mixed>>
*/
public function location(string $location, array $fallback = []): array
{
try {
if (! Schema::hasTable('menus')) {
return $fallback;
}
$menu = Menu::query()->where('enabled', true)->where('location', $location)->first();
if (! $menu || ! is_array($menu->items)) {
return $fallback;
}
$items = $this->normaliseItems($menu->items);
return $items === [] ? $fallback : $items;
} catch (Throwable) {
return $fallback;
}
}
/** @param array<int, mixed> $items
* @return array<int, array<string, mixed>>
*/
private function normaliseItems(array $items): array
{
return collect($items)
->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL))
->map(function (array $item): ?array {
$label = trim((string) ($item['label'] ?? ''));
$url = trim((string) ($item['url'] ?? ''));
if ($label === '' || $url === '') return null;
return [
'label' => $label,
'url' => $url,
'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null,
'children' => $this->normaliseItems((array) ($item['children'] ?? [])),
];
})->filter()->values()->all();
}
/** @return array<int, array<string, mixed>> */
private function fallbackPrimary(): array
{
$items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]];
if (Route::has('blog.public.index')) $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
if (Route::has('news.public.index')) $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
return $items;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Stylist\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Stylist';
}
public static function nameLower(): string
{
return 'stylist';
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
final class CommonApplicationViewsTest extends TestCase
{
public function test_common_theme_owns_application_authentication_and_settings_views(): void
{
foreach ([
'stylist-theme-common::components.layouts.app',
'stylist-theme-common::components.settings.layout',
'stylist-theme-common::partials.settings-heading',
'stylist-theme-common::livewire.settings.profile',
'stylist-theme-common::livewire.settings.appearance',
'stylist-theme-common::livewire.settings.security',
'stylist-theme-common::livewire.auth.login',
'stylist-theme-common::livewire.auth.register',
] as $view) {
$this->assertTrue(view()->exists($view), "Expected common view [{$view}] to exist.");
}
}
public function test_authentication_and_settings_views_are_not_duplicated(): void
{
$this->assertDirectoryDoesNotExist(resource_path('views'));
$this->assertDirectoryDoesNotExist(resource_path('themes/admin/views/livewire'));
$this->assertDirectoryDoesNotExist(resource_path('themes/welcome/views/livewire'));
$commonViews = resource_path('themes/common/views');
foreach ([
'livewire/auth/login.blade.php',
'livewire/auth/register.blade.php',
'livewire/auth/forgot-password.blade.php',
'livewire/auth/reset-password.blade.php',
'livewire/settings/profile.blade.php',
'livewire/settings/appearance.blade.php',
'livewire/settings/security.blade.php',
] as $relativePath) {
$matches = collect(File::allFiles(resource_path('themes')))
->filter(fn ($file) => str_ends_with(
str_replace('\\', '/', $file->getPathname()),
'/views/'.$relativePath
));
$this->assertCount(1, $matches, "Expected one authoritative view for [{$relativePath}].");
$this->assertStringStartsWith(
str_replace('\\', '/', $commonViews),
str_replace('\\', '/', $matches->first()->getPathname())
);
}
}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Tests\TestCase;
final class LegacyThemeNamespaceAliasTest extends TestCase
{
public function test_legacy_theme_namespace_resolves_to_application_theme_directory(): void
{
$this->assertTrue(view()->exists('stylist-module-theme-admin::dashboard'));
$this->assertTrue(view()->exists('stylist-theme-admin::dashboard'));
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Theme\Stylist;
use Tests\TestCase;
final class StylistModuleTest extends TestCase
{
public function test_stylist_service_resolves(): void
{
$this->assertInstanceOf(Stylist::class, app(Stylist::class));
}
public function test_default_theme_resolves(): void
{
config(['stylist.default_theme' => 'admin']);
$this->assertSame('admin', app(Stylist::class)->current()->key());
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
final class ThemeAssetControllerTest extends TestCase
{
public function test_active_theme_css_is_served(): void
{
config(['stylist.themes_path' => resource_path('themes')]);
app(Loader::class)->flush();
$this->get(route('stylist.theme.asset', [
'theme' => 'saasland',
'path' => 'css/theme.css',
]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
}
public function test_asset_url_uses_public_themes_path(): void
{
$url = route('stylist.theme.asset', [
'theme' => 'saasland',
'path' => 'images/example.jpg',
], false);
$this->assertSame('/themes/saasland/images/example.jpg', $url);
}
public function test_non_asset_theme_files_are_not_served(): void
{
config(['stylist.themes_path' => resource_path('themes')]);
app(Loader::class)->flush();
$this->get(route('stylist.theme.asset', [
'theme' => 'saasland',
'path' => 'views/home.blade.php',
]))->assertNotFound();
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Tests\TestCase;
final class ThemeHtmlBuilderTest extends TestCase
{
public function test_css_variables_are_rendered(): void
{
config(['stylist.fallback_variables.brand-primary' => '#111827']);
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
$this->assertStringContainsString('--brand-primary: #111827;', $html);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Diagnostics\ThemeInspector;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\View\ThemeViewResolver;
use Tests\TestCase;
final class ThemeInheritanceTest extends TestCase
{
private string $themesPath;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-inheritance-'.uniqid());
mkdir($this->themesPath, 0777, true);
config([
'stylist.themes_path' => $this->themesPath,
'stylist.default_theme' => 'common',
]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
parent::tearDown();
}
public function test_child_theme_resolves_views_through_parent_without_copying_them(): void
{
$this->theme('common', null, ['components/layouts/app.blade.php' => '<main>{{ $slot }}</main>']);
$this->theme('admin', 'common', ['dashboard.blade.php' => '<h1>Dashboard</h1>']);
app(Loader::class)->flush();
$candidates = app(ThemeViewResolver::class)->candidatesForTheme('admin', 'components.layouts.app');
$this->assertSame('stylist-theme-admin::components.layouts.app', $candidates[0]);
$this->assertContains('stylist-theme-common::components.layouts.app', $candidates);
$this->assertLessThan(
array_search('stylist-theme-common::components.layouts.app', $candidates, true),
array_search('stylist-theme-admin::components.layouts.app', $candidates, true),
);
}
public function test_parent_alias_is_normalised_to_extends(): void
{
$path = $this->themesPath.'/child';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Child',
'parent' => 'common',
]));
$theme = app(Loader::class)->fromPath($path);
$this->assertSame('common', $theme->parent());
}
public function test_duplicate_view_ownership_is_reported(): void
{
$this->theme('common', null, ['livewire/auth/login.blade.php' => 'common']);
$this->theme('admin', 'common', ['livewire/auth/login.blade.php' => 'admin']);
app(Loader::class)->flush();
$duplicates = app(ThemeInspector::class)->duplicateViews();
$this->assertArrayHasKey('livewire/auth/login.blade.php', $duplicates);
$this->assertEqualsCanonicalizing(['common', 'admin'], array_column($duplicates['livewire/auth/login.blade.php'], 'theme'));
}
/** @param array<string, string> $views */
private function theme(string $key, ?string $parent, array $views): void
{
$path = $this->themesPath.'/'.$key;
mkdir($path.'/views', 0777, true);
file_put_contents($path.'/theme.json', json_encode(array_filter([
'name' => ucfirst($key),
'slug' => $key,
'extends' => $parent,
], fn ($value) => $value !== null)));
foreach ($views as $relative => $contents) {
$file = $path.'/views/'.$relative;
if (! is_dir(dirname($file))) {
mkdir(dirname($file), 0777, true);
}
file_put_contents($file, $contents);
}
}
}
@@ -0,0 +1,157 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
final class ThemeManifestTest extends TestCase
{
private string $themesPath;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-themes-'.uniqid());
mkdir($this->themesPath, 0777, true);
config(['stylist.themes_path' => $this->themesPath]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
parent::tearDown();
}
public function test_manifest_is_normalised(): void
{
$path = $this->themesPath.'/sample';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample']));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame('sample', $theme->key());
$this->assertSame('css/theme.css', $theme->stylesheet());
$this->assertSame('js/app.js', $theme->script());
$this->assertSame('home', $theme->entryView());
}
public function test_unsafe_manifest_path_is_rejected(): void
{
$path = $this->themesPath.'/unsafe';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Unsafe',
'stylesheet' => '../secret.css',
]));
$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';
mkdir($path.'/css', 0777, true);
mkdir($path.'/js', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'key' => 'design-gallery',
'name' => 'Design Gallery',
'stylesheet' => 'css/app.css',
'script' => 'js/app.js',
'appearances' => ['light', 'dark', 'system'],
'accents' => ['blue', 'emerald', 'neutral'],
'default_appearance' => 'system',
'default_accent' => 'neutral',
// Phase 1 incorrectly duplicated accents here; Stylist must not
// reject the whole theme because of that compact legacy list.
'variants' => ['blue', 'emerald', 'neutral'],
]));
file_put_contents($path.'/css/app.css', 'body{}');
file_put_contents($path.'/js/app.js', '');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances()));
$this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents()));
$this->assertSame([], $theme->variants());
$this->assertSame('system', $theme->defaultAppearance());
$this->assertSame('neutral', $theme->defaultAccent());
}
public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void
{
$path = $this->themesPath.'/legacy';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy']));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame([], $theme->appearances());
$this->assertSame([], $theme->accents());
$this->assertSame('', $theme->defaultAppearance());
$this->assertSame('', $theme->defaultAccent());
}
public function test_defaults_must_reference_declared_optional_capabilities(): void
{
$path = $this->themesPath.'/invalid-default';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Invalid Default',
'appearances' => ['light', 'dark'],
'default_appearance' => 'system',
]));
$this->expectException(InvalidThemeManifestException::class);
app(Loader::class)->fromPath($path);
}
public function test_manifest_version_and_capabilities_are_normalised(): void
{
$path = $this->themesPath.'/capable';
mkdir($path.'/css', 0777, true);
mkdir($path.'/js', 0777, true);
mkdir($path.'/views', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'manifest_version' => 1,
'name' => 'Capable',
'appearances' => ['light', 'dark'],
'accents' => ['blue'],
'capabilities' => ['marketplace' => true],
]));
file_put_contents($path.'/css/theme.css', 'body{}');
file_put_contents($path.'/js/app.js', '');
file_put_contents($path.'/views/home.blade.php', '<div>Capable</div>');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(1, $theme->manifestVersion());
$this->assertTrue($theme->hasCapability('appearances'));
$this->assertTrue($theme->hasCapability('accents'));
$this->assertTrue($theme->hasCapability('marketplace'));
$this->assertFalse($theme->hasCapability('preview'));
}
public function test_unknown_manifest_fields_are_preserved_without_preventing_loading(): void
{
$path = $this->themesPath.'/future';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Future',
'future_builder_metadata' => ['anything' => true],
]));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Stylist\Services\ThemePackageManager;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
use ZipArchive;
final class ThemePackageRoundTripTest extends TestCase
{
private string $themesPath;
private string $workspace;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-installed-'.uniqid());
$this->workspace = storage_path('framework/testing/stylist-package-'.uniqid());
mkdir($this->themesPath, 0777, true);
mkdir($this->workspace, 0777, true);
config(['stylist.themes_path' => $this->themesPath]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
app(Filesystem::class)->deleteDirectory($this->workspace);
app(Loader::class)->flush();
parent::tearDown();
}
public function test_generated_theme_zip_installs_discovers_and_renders(): void
{
$source = $this->workspace.'/design-gallery';
mkdir($source.'/css', 0777, true);
mkdir($source.'/js', 0777, true);
mkdir($source.'/views', 0777, true);
file_put_contents($source.'/theme.json', json_encode([
'manifest_version' => 1,
'key' => 'design-gallery',
'name' => 'Design Gallery',
'stylesheet' => 'css/app.css',
'script' => 'js/app.js',
'entry_view' => 'home',
'appearances' => ['light', 'dark', 'system'],
'accents' => ['blue', 'neutral'],
'default_appearance' => 'system',
'default_accent' => 'neutral',
]));
file_put_contents($source.'/css/app.css', 'body{}');
file_put_contents($source.'/js/app.js', '');
file_put_contents($source.'/views/home.blade.php', '<main>Design Gallery works</main>');
$zipPath = $this->workspace.'/design-gallery.zip';
$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());
}
$zip->close();
$upload = new UploadedFile($zipPath, 'design-gallery.zip', 'application/zip', null, true);
$theme = app(ThemePackageManager::class)->install($upload);
$this->assertSame('design-gallery', $theme->key());
$this->assertTrue(app(Loader::class)->all()->has('design-gallery'));
$this->assertStringContainsString(
'Design Gallery works',
stylist_theme_view('design-gallery', 'home')->render(),
);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\View\ThemeViewResolver;
use Tests\TestCase;
final class ThemeViewResolverTest extends TestCase
{
public function test_theme_view_candidates_prefer_active_theme_then_default(): void
{
config([
'stylist.default_theme' => 'admin',
'stylist.themes_path' => resource_path('themes'),
]);
$candidates = app(ThemeViewResolver::class)->candidates('components.page-hero');
$this->assertContains('stylist-theme-admin::components.page-hero', $candidates);
$this->assertContains('stylist::components.page-hero', $candidates);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Tests\TestCase;
final class WelcomeThemeViewTest extends TestCase
{
public function test_bundled_saasland_theme_view_namespace_is_registered(): void
{
$this->assertTrue(view()->exists('stylist-theme-saasland::home'));
}
public function test_home_route_renders_the_saasland_theme(): void
{
$this->get('/')
->assertOk()
->assertViewIs('stylist-theme-saasland::home')
->assertSee('Transform Your Business With Our SaaS Solution');
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class InvalidThemeManifestException extends RuntimeException
{
/** @param array<int, string> $errors */
public static function forPath(string $path, array $errors): self
{
return new self(sprintf(
'Invalid theme manifest at [%s]: %s',
rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json',
implode(' ', $errors),
));
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeJsonNotFoundException extends RuntimeException
{
public static function forPath(string $path): self
{
return new self("Theme manifest [{$path}/theme.json] was not found.");
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeNotFoundException extends RuntimeException
{
public static function forKey(string $key): self
{
return new self("Theme [{$key}] was not found.");
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Exceptions\ThemeJsonNotFoundException;
final class Json
{
public function __construct(private readonly Filesystem $files) {}
public function read(string $path): array
{
$manifest = rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json';
if (! $this->files->exists($manifest)) {
throw ThemeJsonNotFoundException::forPath($path);
}
return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Collection;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Loader
{
private ?Collection $themes = null;
public function __construct(
private readonly Filesystem $files,
private readonly Json $json,
private readonly ManifestValidator $validator,
) {}
public function all(): Collection
{
if ($this->themes !== null) {
return $this->themes;
}
$themes = collect();
foreach ($this->roots() as $root) {
if (! $this->files->isDirectory($root)) {
continue;
}
foreach ($this->files->directories($root) as $themePath) {
try {
$theme = $this->fromPath($themePath);
$themes->put($theme->key(), $theme);
} catch (\Throwable $exception) {
report($exception);
}
}
}
return $this->themes = $themes->sortKeys();
}
public function find(string $key): Theme
{
$theme = $this->all()->get($key);
if (! $theme) {
throw ThemeNotFoundException::forKey($key);
}
return $theme;
}
public function fromPath(string $path): Theme
{
$data = $this->validator->validate($this->json->read($path), $path);
return new Theme(
key: $data['key'],
name: $data['name'],
path: rtrim($path, DIRECTORY_SEPARATOR),
stylesheet: $data['stylesheet'],
script: $data['script'],
variables: $data['variables'],
meta: $data,
);
}
public function flush(): void
{
$this->themes = null;
}
/** @return array<int, string> */
public function roots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
final class ManifestValidator
{
/** @return array<string, mixed> */
public function validate(array $manifest, string $path): array
{
$errors = [];
$manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1;
if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) {
$manifestVersion = (int) $manifestVersion;
}
if (! is_int($manifestVersion) || $manifestVersion < 1) {
$errors[] = 'The manifest_version must be a positive integer.';
}
$key = $manifest['key'] ?? $manifest['slug'] ?? basename($path);
if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.';
}
if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) {
$errors[] = 'The name must be a non-empty string.';
}
if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) {
$errors[] = 'The version must use semantic versioning, for example 1.2.0.';
}
if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) {
$errors[] = 'The type must be frontend, backend, or common.';
}
$parent = $manifest['extends'] ?? $manifest['parent'] ?? null;
if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) {
$errors[] = 'The extends/parent value must be a valid theme key.';
}
foreach (['stylesheet', 'script', 'preview'] as $field) {
if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) {
$errors[] = "The {$field} value must be a safe relative path.";
}
}
foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) {
if (isset($manifest[$field]) && ! is_array($manifest[$field])) {
$errors[] = "The {$field} value must be an object or array.";
}
}
$variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false);
$appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors);
$accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors);
$capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors);
$capabilities['appearances'] = $appearances !== [];
$capabilities['accents'] = $accents !== [];
$capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp');
$defaultAppearance = $manifest['default_appearance'] ?? null;
if ($defaultAppearance !== null) {
if (! is_string($defaultAppearance) || $defaultAppearance === '') {
$errors[] = 'The default_appearance value must be a non-empty string.';
} elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) {
$errors[] = 'The default_appearance value must be declared in appearances.';
}
}
$defaultAccent = $manifest['default_accent'] ?? null;
if ($defaultAccent !== null) {
if (! is_string($defaultAccent) || $defaultAccent === '') {
$errors[] = 'The default_accent value must be a non-empty string.';
} elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) {
$errors[] = 'The default_accent value must be declared in accents.';
}
}
if ($errors !== []) {
throw InvalidThemeManifestException::forPath($path, $errors);
}
$manifest['manifest_version'] = $manifestVersion;
unset($manifest['schema_version']);
$manifest['key'] = $key;
$manifest['slug'] = $key;
$manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString();
$manifest['extends'] = $parent;
$manifest['version'] = (string) ($manifest['version'] ?? '1.0.0');
$manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home');
$manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css';
$manifest['script'] = $manifest['script'] ?? 'js/app.js';
$manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [];
$manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : [];
$manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : [];
$manifest['capabilities'] = $capabilities;
$manifest['variants'] = $variants;
$manifest['appearances'] = $appearances;
$manifest['accents'] = $accents;
$manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : [];
$manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : [];
$manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null);
return $manifest;
}
/**
* 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
* @return array<string, array<string, mixed>>
*/
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
{
if ($value === []) {
return [];
}
if (array_is_list($value)) {
if (! $allowCompact && $field === 'variants') {
// Older Theme Builder Phase 1 builds incorrectly wrote accent names
// to variants. Ignore that list while preserving object-style variants.
return [];
}
$normalised = [];
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;
}
$normalised = [];
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;
}
return $normalised;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, bool>
*/
private function normaliseCapabilities(array $value, array &$errors): array
{
$normalised = [];
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;
}
$normalised[$name] = $enabled;
}
return $normalised;
}
private function isSafeRelativePath(mixed $path): bool
{
return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0");
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Services\TenantThemeResolver;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Stylist
{
public function __construct(private readonly Loader $loader, private readonly TenantThemeResolver $resolver, private readonly ThemeSettings $settings) {}
public function current(?string $context = null): Theme
{
$requested = $this->resolver->resolve($context);
try {
$theme = $this->loader->find($requested);
if ($context !== null && $theme->type() !== $context) {
throw ThemeNotFoundException::forKey($requested);
}
return $theme;
} catch (ThemeNotFoundException) {
$fallback = $context === 'frontend'
? (string) config('stylist.default_frontend_theme', 'sonardesk')
: (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'sonardesk-admin'));
if ($requested !== $fallback) {
return $this->loader->find($fallback);
}
throw ThemeNotFoundException::forKey($requested);
}
}
public function frontend(): Theme
{
return $this->current('frontend');
}
public function backend(): Theme
{
return $this->current('backend');
}
public function variables(): array
{
$theme = $this->current();
$variant = $this->settings->variant($theme);
$variantVars = (array) data_get($theme->variants(), $variant.'.variables', []);
$appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []);
$accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []);
$settingVars = collect($this->settings->get($theme))
->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--'))
->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value])
->all();
return array_replace(
(array) config('stylist.fallback_variables', []),
$this->inheritedVariables($theme),
$variantVars,
$appearanceVars,
$accentVars,
$settingVars,
$this->resolver->variables(),
);
}
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 [];
}
$seen[] = $theme->key();
$parent = [];
if ($theme->parent()) {
try {
$parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
} catch (ThemeNotFoundException) {
}
}
return array_replace($parent, $theme->variables());
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Modules\Stylist\Theme;
final class Theme
{
public function __construct(
private readonly string $key,
private readonly string $name,
private readonly string $path,
private readonly string $stylesheet = 'css/theme.css',
private readonly string $script = 'js/app.js',
private readonly array $variables = [],
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 type(): string
{
return (string) ($this->meta['type'] ?? 'frontend');
}
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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
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 forTheme(Theme|string $theme, string $path): string
{
$key = $theme instanceof Theme ? $theme->key() : $theme;
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace Modules\Stylist\View;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\View\View;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use RuntimeException;
final class ThemeViewResolver
{
/** @var array<string, array<int, string>> */
private array $candidateCache = [];
public function __construct(
private readonly Stylist $stylist,
private readonly ViewFactory $views,
private readonly Loader $loader,
) {}
public function make(string $view, array $data = [], array $mergeData = []): View
{
return $this->views->first(
$this->candidates($view),
array_replace([
'themeSettings' => $this->stylist->settings(),
'themeVariant' => $this->stylist->variant(),
], $data),
$mergeData,
);
}
public function makeForTheme(string $theme, string $view, array $data = [], array $mergeData = []): View
{
return $this->views->first($this->candidatesForTheme($theme, $view), $data, $mergeData);
}
/** @return array<int, string> */
public function candidates(string $view): array
{
return $this->candidatesForTheme($this->stylist->current()->key(), $view);
}
/** @return array<int, string> */
public function candidatesForTheme(string $theme, string $view): array
{
$view = ltrim($view, '.');
$cacheKey = $theme.'|'.$view;
if (isset($this->candidateCache[$cacheKey])) {
return $this->candidateCache[$cacheKey];
}
$keys = $this->inheritanceChain($theme);
$fallback = (string) config('stylist.default_theme', 'common');
if ($fallback !== '' && ! in_array($fallback, $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain($fallback));
} catch (ThemeNotFoundException) {
// A missing configured fallback is diagnosed by stylist:doctor.
}
}
if (! in_array('common', $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain('common'));
} catch (ThemeNotFoundException) {
// The module fallback below remains available.
}
}
$candidates = [];
foreach (array_values(array_unique($keys)) as $key) {
$this->registerNamespace($key);
$candidates[] = "stylist-theme-{$key}::{$view}";
$candidates[] = "stylist-module-theme-{$key}::{$view}";
}
$candidates[] = "stylist::{$view}";
$candidates[] = $view;
return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates));
}
public function flush(): void
{
$this->candidateCache = [];
}
/** @return array<int, string> */
private function inheritanceChain(string $theme): array
{
$keys = [];
$seen = [];
$key = $theme;
while ($key !== '') {
if (isset($seen[$key])) {
throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].');
}
$seen[$key] = true;
$loaded = $this->loader->find($key);
$keys[] = $loaded->key();
$key = $loaded->parent() ?? '';
}
return $keys;
}
private function registerNamespace(string $theme): void
{
try {
$loaded = $this->loader->find($theme);
} catch (ThemeNotFoundException) {
return;
}
$viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$this->views->addNamespace("stylist-theme-{$theme}", $viewsPath);
$this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath);
}
}
+24
View File
@@ -0,0 +1,24 @@
latest: 1.1.0
releases:
- version: 1.1.0
date: 2026-07-08
changes:
- Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views.
- Added ThemeViewResolver and stylist_view() fallback helper.
- Added default and MPBA example themes.
- Added theme publishing support.
- Updated Vite inputs to include theme CSS files.
- version: 1.0.0
date: 2026-07-08
changes:
- Initial Laravel 13 Stylist module.
- version: 1.1.0
date: 2026-07-23
changes:
- Added optional manifest_version/schema_version normalisation.
- Added capability normalisation for appearances, accents, previews and future features.
- Added stylist:validate for installed themes, directories and ZIP packages.
- Added complete package validation during installation.
- Added dynamic view namespace registration after installing a theme.
- Added manifest compatibility and install-render regression coverage.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mpba/stylist-module",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"type": "laravel-module",
"require": {
"php": "^8.3",
"illuminate/support": "^13.0",
"illuminate/view": "^13.0",
"illuminate/filesystem": "^13.0",
"illuminate/console": "^13.0"
},
"autoload": {
"psr-4": {
"Modules\\Stylist\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Stylist\\Tests\\": "Tests/"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Stylist",
"alias": "stylist",
"version": "1.1.0",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"keywords": [
"laravel",
"modules",
"themes",
"tenancy",
"styling"
],
"priority": 0,
"providers": [
"Modules\\Stylist\\Providers\\StylistServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+15
View File
@@ -0,0 +1,15 @@
{
"scripts": {
"dev": "vite",
"build": "vite build",
"stylist:dev": "vite --config Modules/Stylist/vite.config.js",
"stylist:build": "vite build --config Modules/Stylist/vite.config.js",
"matches:dev": "vite --config Modules/Matches/vite.config.js",
"matches:build": "vite build --config Modules/Matches/vite.config.js",
"modules:build": "npm run stylist:build && npm run matches:build",
"build:all": "npm run build && npm run modules:build"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: [
'Modules/Stylist/Resources/assets/css/app.css',
'Modules/Stylist/Resources/assets/js/app.js',
],
refresh: [
'Modules/Stylist/Resources/views/**',
'resources/themes/**/views/**',
'Modules/Stylist/App/**',
'Modules/Stylist/routes/**',
],
buildDirectory: 'build/stylist',
}),
tailwindcss(),
],
});
@@ -0,0 +1,14 @@
{
"manifest_version": 1,
"key": "sonardesk-admin",
"name": "SonarDesk Administration",
"version": "1.0.0",
"type": "backend",
"description": "Original SonarDesk design, independently replaceable through Stylist.",
"entry_view": "admin.index",
"variants": {
"light": {
"variables": {}
}
}
}
@@ -9,7 +9,7 @@
@forelse($products as $product)<tr><td><strong>{{ $product->name }}</strong><small>{{ \Illuminate\Support\Str::limit($product->description ?: $product->original_filename, 70) }}</small></td><td><strong>{{ $product->version ?: '—' }}</strong><small>{{ $product->platform ?: 'Unspecified' }} · {{ $product->formatted_size }}</small></td><td>{{ number_format($product->download_count) }}</td><td><span class="status-chip {{ $product->is_published ? 'success' : 'muted' }}">{{ $product->is_published ? 'Published' : 'Draft' }}</span></td><td><div class="distribution-row-actions"><button type="button" class="dist-action" onclick="const panel=document.getElementById('edit-product-{{ $product->id }}');panel.open=true;panel.scrollIntoView({behavior:'smooth',block:'start'});">Edit</button><form method="post" action="{{ route('admin.download-products.publication', $product) }}">@csrf @method('PATCH')<input type="hidden" name="is_published" value="{{ $product->is_published ? '0' : '1' }}"><button type="submit" class="dist-action {{ $product->is_published ? 'dist-unpublish' : 'dist-publish' }}">{{ $product->is_published ? 'Unpublish' : 'Publish' }}</button></form></div></td></tr>
@empty<tr><td colspan="5" class="empty-cell">No additional products yet. Add your first installer below.</td></tr>@endforelse
</tbody></table></div></section>
<details class="distribution-panel" id="new-product" @if($errors->any() && !old('_method')) open @endif><summary><span><strong>Add a new product</strong><small>Upload an installer and choose whether to publish it.</small></span><span class="dist-chevron">+</span></summary><form class="distribution-form" method="post" action="{{ route('admin.download-products.store') }}" enctype="multipart/form-data">@csrf<div class="form-grid"><label>Product name<input name="name" value="{{ old('name') }}" required maxlength="150" placeholder="e.g. SonarDesk CLI"></label><label>Version<input name="version" value="{{ old('version') }}" maxlength="80" placeholder="e.g. 2.1.0"></label><label>Platform<input name="platform" value="{{ old('platform') }}" maxlength="120" placeholder="macOS, Windows, Linux…"></label></div><label>Description<textarea name="description" rows="3" maxlength="3000" placeholder="Describe what this product does">{{ old('description') }}</textarea></label><label class="distribution-file">Installer / package <small>Maximum 1 GB</small><input type="file" name="package" required></label><label class="distribution-check"><input type="checkbox" name="is_published" value="1" @checked(old('is_published'))><span><strong>Publish immediately</strong><small>Make this download available on the public page.</small></span></label><button class="btn btn-primary" type="submit">Add product</button></form></details>
@foreach($products as $product)<details class="distribution-panel" id="edit-product-{{ $product->id }}"><summary><span><strong>Edit {{ $product->name }}</strong><small>{{ $product->original_filename }} · {{ $product->formatted_size }}</small></span><span class="dist-chevron">+</span></summary><form class="distribution-form" method="post" action="{{ route('admin.download-products.update',$product) }}" enctype="multipart/form-data">@csrf @method('PUT')<div class="form-grid"><label>Product name<input name="name" value="{{ $product->name }}" required maxlength="150"></label><label>Version<input name="version" value="{{ $product->version }}" maxlength="80"></label><label>Platform<input name="platform" value="{{ $product->platform }}" maxlength="120"></label></div><label>Description<textarea name="description" rows="3" maxlength="3000">{{ $product->description }}</textarea></label><label class="distribution-file">Replace package (optional)<small>Current: {{ $product->original_filename }} · Maximum 1 GB</small><input type="file" name="package"></label><label class="distribution-check"><input type="checkbox" name="is_published" value="1" @checked($product->is_published)><span><strong>Published</strong><small>Untick to save this product as a draft.</small></span></label><button class="btn btn-primary" type="submit">Save changes</button></form><form class="distribution-delete" method="post" action="{{ route('admin.download-products.destroy',$product) }}" onsubmit="return confirm('Permanently delete this product and its installer?')">@csrf @method('DELETE')<button type="submit" class="danger-outline">Delete product</button></form></details>@endforeach
<details class="distribution-panel" id="new-product" @if($errors->any() && !old('_method')) open @endif><summary><span><strong>Add a new product</strong><small>Upload an installer and choose whether to publish it.</small></span><span class="dist-chevron">+</span></summary><form class="distribution-form" method="post" action="{{ route('admin.download-products.store') }}" enctype="multipart/form-data">@csrf<div class="form-grid"><label>Product name<input name="name" value="{{ old('name') }}" required maxlength="150" placeholder="e.g. SonarDesk CLI"></label><label>Version<input name="version" value="{{ old('version') }}" maxlength="80" placeholder="e.g. 2.1.0"></label><label>Platform<input name="platform" value="{{ old('platform') }}" maxlength="120" placeholder="macOS, Windows, Linux…"></label></div><label>Description (Markdown supported)<textarea name="description" rows="12" maxlength="12000" placeholder="## Overview&#10;&#10;Describe the product.&#10;&#10;## Features&#10;&#10;- Feature one&#10;- Feature two">{{ old('description') }}</textarea></label><label class="distribution-file">Installer / package <small>Maximum 1 GB</small><input type="file" name="package" required></label><label class="distribution-check"><input type="checkbox" name="is_published" value="1" @checked(old('is_published'))><span><strong>Publish immediately</strong><small>Make this download available on the public page.</small></span></label><button class="btn btn-primary" type="submit">Add product</button></form></details>
@foreach($products as $product)<details class="distribution-panel" id="edit-product-{{ $product->id }}"><summary><span><strong>Edit {{ $product->name }}</strong><small>{{ $product->original_filename }} · {{ $product->formatted_size }}</small></span><span class="dist-chevron">+</span></summary><form class="distribution-form" method="post" action="{{ route('admin.download-products.update',$product) }}" enctype="multipart/form-data">@csrf @method('PUT')<div class="form-grid"><label>Product name<input name="name" value="{{ $product->name }}" required maxlength="150"></label><label>Version<input name="version" value="{{ $product->version }}" maxlength="80"></label><label>Platform<input name="platform" value="{{ $product->platform }}" maxlength="120"></label></div><label>Description (Markdown supported)<textarea name="description" rows="12" maxlength="12000">{{ $product->description }}</textarea></label><label class="distribution-file">Replace package (optional)<small>Current: {{ $product->original_filename }} · Maximum 1 GB</small><input type="file" name="package"></label><label class="distribution-check"><input type="checkbox" name="is_published" value="1" @checked($product->is_published)><span><strong>Published</strong><small>Untick to save this product as a draft.</small></span></label><button class="btn btn-primary" type="submit">Save changes</button></form><form class="distribution-delete" method="post" action="{{ route('admin.download-products.destroy',$product) }}" onsubmit="return confirm('Permanently delete this product and its installer?')">@csrf @method('DELETE')<button type="submit" class="danger-outline">Delete product</button></form></details>@endforeach
</div>
@endsection
@@ -0,0 +1,6 @@
@extends('layouts.app')
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount ?? '—' }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount ?? '—' }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount ?? '—' }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount ?? '—' }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('stylist.index') }}">Manage themes</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection
@@ -0,0 +1,6 @@
@extends('layouts.app')
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('stylist.index') }}">Manage themes</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection

Some files were not shown because too many files have changed in this diff Show More