download fixes

This commit is contained in:
Mark Gregory
2026-10-20 09:57:43 +01:00
parent 1dab26931a
commit bfa4a2312c
68 changed files with 3541 additions and 202 deletions
@@ -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);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'default_theme' => env('STYLIST_DEFAULT_THEME', 'admin'),
'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'admin'),
'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'saasland'),
/*
| 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\Settings\Services\SettingsRepository;
final class StylistController extends Controller
{
public function __invoke(Stylist $stylist, ThemeSettings $settings, ThemeAccessibilityValidator $accessibility, UrlGenerator $urls, SettingsRepository $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', 'saasland'), 'site'),
'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', '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,87 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Settings\Services\SettingsRepository;
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, SettingsRepository $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);
return $views->makeForTheme($item->key(), $item->entryView(), [
'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);
$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))]);
}
}
+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,215 @@
<?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\Settings\Services\SettingsRepository;
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
{
$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(SettingsRepository::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,78 @@
<x-app-layout>
<x-wms.page-hero eyebrow="Administration" title="Stylist" subtitle="Install, configure, validate and export self-contained themes." />
@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>
</x-app-layout>
+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');
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\StylistController;
use Modules\Stylist\Http\Controllers\ThemeManagementController;
Route::middleware(['auth', 'verified'])->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');
});
@@ -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 Illuminate\Support\Facades\Auth;
use Modules\Settings\Services\SettingsRepository;
final class TenantThemeResolver
{
public function __construct(private readonly SettingsRepository $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', 'saasland')
: config('stylist.default_backend_theme', config('stylist.default_theme', 'admin'));
return (string) $this->settings->get($settingKey, $default, 'site');
}
public function context(): string
{
if (app()->runningInConsole()) {
return 'backend';
}
$request = request();
return $request->routeIs('home', 'blog.public.*', 'news.public.*', 'pages.public.*')
|| ! $request->user()
? 'frontend'
: 'backend';
}
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,98 @@
<?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\Settings\Services\SettingsRepository;
use RuntimeException;
use ZipArchive;
final class ThemePackageManager
{
public function __construct(private readonly Filesystem $files, private readonly Loader $loader, private readonly ThemeValidator $validator, private readonly SettingsRepository $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
{
$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");
}
}
+103
View File
@@ -0,0 +1,103 @@
<?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 {
return $this->loader->find($requested);
} catch (ThemeNotFoundException) {
$fallback = $context === 'frontend'
? (string) config('stylist.default_frontend_theme', 'saasland')
: (string) config('stylist.default_backend_theme', config('stylist.default_theme', '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(),
],
});