download fixes
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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))]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 ?: '[]').'); ?>');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '/')]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user