initial commmit
tests / ci (push) Canceled after 0s

This commit is contained in:
Mark Gregory
2026-09-30 14:24:42 +01:00
parent 4bff627a79
commit 0a74f5b44e
241 changed files with 27923 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'default_theme' => env('STYLIST_DEFAULT_THEME', 'backend'),
'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'backend'),
'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'frontend'),
/*
| Themes are application resources, never module resources.
| Every theme is self-contained and owns its views and assets.
*/
'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')),
'tenant_theme_column' => 'theme_key',
'tenant_brand_columns' => [
'brand_primary' => 'brand-primary',
'brand_secondary' => 'brand-secondary',
'brand_accent' => 'brand-accent',
],
'fallback_variables' => [
'brand-primary' => '#2563eb',
'brand-secondary' => '#0f172a',
'brand-accent' => '#22c55e',
'surface' => '#ffffff',
'surface-muted' => '#f8fafc',
'text-primary' => '#0f172a',
],
];
@@ -0,0 +1,64 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
final class CreateThemeCommand extends Command
{
protected $signature = 'stylist:create-theme {key : Lowercase theme key} {--parent=common : Parent theme key} {--name= : Display name}';
protected $description = 'Create a minimal child theme without copying shared views.';
public function __construct(private readonly Filesystem $files)
{
parent::__construct();
}
public function handle(): int
{
$key = strtolower(trim((string) $this->argument('key')));
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
return self::FAILURE;
}
$path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
if ($this->files->exists($path)) {
$this->error("Theme [{$key}] already exists.");
return self::FAILURE;
}
$parent = trim((string) $this->option('parent'));
$name = trim((string) ($this->option('name') ?: str($key)->headline()));
$this->files->ensureDirectoryExists($path.'/views');
$manifest = [
'manifest_version' => 1,
'name' => $name,
'slug' => $key,
'version' => '1.0.0',
'description' => "Child theme for {$name}.",
'extends' => $parent !== '' ? $parent : null,
'entry_view' => 'home',
'views' => true,
'variables' => [],
'settings' => [],
];
if ($manifest['extends'] === null) {
unset($manifest['extends']);
}
$this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
$this->files->put($path.'/views/.gitkeep', '');
$this->info("Theme [{$key}] created at {$path}.");
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
return self::SUCCESS;
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class FindDuplicateViewsCommand extends Command
{
protected $signature = 'stylist:duplicates';
protected $description = 'Find Blade task paths owned by more than one theme.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$duplicates = $this->inspector->duplicateViews();
if ($duplicates === []) {
$this->info('No duplicate theme views found.');
return self::SUCCESS;
}
$rows = [];
foreach ($duplicates as $view => $owners) {
foreach ($owners as $owner) {
$rows[] = [$view, $owner['theme'], $owner['path']];
}
}
$this->table(['View', 'Theme', 'Path'], $rows);
$this->error(count($duplicates).' duplicate view task(s) found.');
return self::FAILURE;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class InspectThemesCommand extends Command
{
protected $signature = 'stylist:inspect';
protected $description = 'Display installed themes, inheritance, entry views, and view counts.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$themes = $this->inspector->themes();
$this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [
$theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'],
], $themes));
return self::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
final class PublishAssetsCommand extends Command
{
protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}';
protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.';
public function handle(): int
{
$force = (bool) $this->option('force');
$this->call('vendor:publish', [
'--tag' => 'stylist-config',
'--force' => $force,
]);
$themesPath = (string) config('stylist.themes_path', resource_path('themes'));
if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) {
$this->components->error("Unable to create theme directory: {$themesPath}");
return self::FAILURE;
}
$this->components->info("Stylist config published. Themes remain in: {$themesPath}");
return self::SUCCESS;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class ThemeDoctorCommand extends Command
{
protected $signature = 'stylist:doctor';
protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$issues = $this->inspector->issues();
if ($issues === []) {
$this->info('Stylist is healthy. No theme issues found.');
return self::SUCCESS;
}
$this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [
strtoupper($issue['severity']), $issue['theme'], $issue['message'],
], $issues));
$errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error'));
$warnings = count($issues) - $errors;
$this->line("{$errors} error(s), {$warnings} warning(s).");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Services\ThemeValidator;
use Modules\Stylist\Theme\Loader;
use RuntimeException;
use ZipArchive;
final class ValidateThemeCommand extends Command
{
protected $signature = 'stylist:validate {theme : Installed theme key, theme directory, or theme ZIP path}';
protected $description = 'Validate a Stylist theme manifest, files, references, and accessibility.';
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
private readonly ThemeValidator $validator,
) {
parent::__construct();
}
public function handle(): int
{
$temporary = null;
try {
[$path, $temporary] = $this->resolveThemePath((string) $this->argument('theme'));
$theme = $this->loader->fromPath($path);
$issues = $this->validator->validate($theme);
$this->info("Theme: {$theme->name()} ({$theme->key()})");
$this->line('Manifest schema: '.$theme->manifestVersion());
if ($issues === []) {
$this->info('Theme validation passed with no issues.');
return self::SUCCESS;
}
$this->table(
['Severity', 'File', 'Message'],
array_map(fn (array $issue) => [
strtoupper($issue['severity']),
$issue['file'],
$issue['message'],
], $issues),
);
$errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error'));
$warnings = count($issues) - $errors;
$this->line("{$errors} error(s), {$warnings} warning(s).");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} finally {
if ($temporary !== null) {
$this->files->deleteDirectory($temporary);
}
}
}
/** @return array{0:string,1:?string} */
private function resolveThemePath(string $input): array
{
if ($this->files->isDirectory($input)) {
return [realpath($input) ?: $input, null];
}
if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') {
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
$zip = new ZipArchive;
if ($zip->open($input) !== true) {
throw new RuntimeException('The theme ZIP could not be opened.');
}
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
$zip->close();
throw new RuntimeException('The ZIP contains an unsafe path.');
}
}
$zip->extractTo($temporary);
$zip->close();
$manifest = collect($this->files->allFiles($temporary))
->first(fn ($file) => $file->getFilename() === 'theme.json');
if (! $manifest) {
throw new RuntimeException('The package does not contain theme.json.');
}
return [$manifest->getPath(), $temporary];
}
return [$this->loader->find($input)->path(), null];
}
}
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
if (! Schema::hasColumn('companies', 'theme_key')) {
$table->string('theme_key')->default('default')->after('timezone');
}
if (! Schema::hasColumn('companies', 'brand_primary')) {
$table->string('brand_primary')->nullable()->after('theme_key');
}
if (! Schema::hasColumn('companies', 'brand_secondary')) {
$table->string('brand_secondary')->nullable()->after('brand_primary');
}
if (! Schema::hasColumn('companies', 'brand_accent')) {
$table->string('brand_accent')->nullable()->after('brand_secondary');
}
if (! Schema::hasColumn('companies', 'logo_path')) {
$table->string('logo_path')->nullable()->after('brand_accent');
}
if (! Schema::hasColumn('companies', 'theme_settings')) {
$table->json('theme_settings')->nullable()->after('logo_path');
}
});
}
public function down(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) {
if (Schema::hasColumn('companies', $column)) {
$table->dropColumn($column);
}
}
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stylist_theme_settings', function (Blueprint $table) {
$table->id();
$table->string('theme_key')->unique();
$table->string('variant')->default('light');
$table->json('settings')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stylist_theme_settings');
}
};
@@ -0,0 +1,147 @@
<?php
namespace Modules\Stylist\Diagnostics;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeInspector
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
) {}
/** @return array<int, array<string, mixed>> */
public function themes(): array
{
return $this->loader->all()->map(function (Theme $theme): array {
return [
'key' => $theme->key(),
'name' => $theme->name(),
'parent' => $theme->parent(),
'path' => $theme->path(),
'views' => count($this->viewFiles($theme)),
'entry_view' => $theme->entryView(),
];
})->values()->all();
}
/** @return array<string, array<int, array{theme:string,path:string}>> */
public function duplicateViews(): array
{
$ownership = [];
foreach ($this->loader->all() as $theme) {
foreach ($this->viewFiles($theme) as $relative => $absolute) {
$ownership[$relative][] = [
'theme' => $theme->key(),
'path' => $absolute,
];
}
}
return array_filter($ownership, fn (array $owners): bool => count($owners) > 1);
}
/** @return array<int, array{severity:string,theme:string,message:string}> */
public function issues(): array
{
$issues = [];
$themes = $this->loader->all();
foreach ($themes as $theme) {
$parent = $theme->parent();
if ($parent !== null && ! $themes->has($parent)) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => "Parent theme [{$parent}] is not installed.",
];
}
if ($this->hasInheritanceCycle($theme, $themes->all())) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => 'Theme inheritance contains a cycle.',
];
}
$entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $this->files->exists($entry) && $parent === null) {
$issues[] = [
'severity' => 'warning',
'theme' => $theme->key(),
'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.",
];
}
}
foreach ($this->duplicateViews() as $view => $owners) {
$issues[] = [
'severity' => 'error',
'theme' => implode(', ', array_column($owners, 'theme')),
'message' => "View [{$view}] has multiple owners.",
];
}
$default = (string) config('stylist.default_theme');
if ($default !== '' && ! $themes->has($default)) {
$issues[] = [
'severity' => 'warning',
'theme' => $default,
'message' => 'Configured default theme is not installed.',
];
}
return $issues;
}
/** @return array<string, string> */
private function viewFiles(Theme $theme): array
{
$root = $theme->path().'/views';
if (! $this->files->isDirectory($root)) {
return [];
}
$views = [];
foreach ($this->files->allFiles($root) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$absolute = str_replace('\\', '/', $file->getPathname());
$relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/');
$views[$relative] = $absolute;
}
ksort($views);
return $views;
}
/** @param array<string, Theme> $themes */
private function hasInheritanceCycle(Theme $theme, array $themes): bool
{
$seen = [];
$key = $theme->key();
while (isset($themes[$key])) {
if (isset($seen[$key])) {
return true;
}
$seen[$key] = true;
$key = $themes[$key]->parent() ?? '';
if ($key === '') {
return false;
}
}
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class StylistFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist';
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class ThemeFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist.html';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
use Illuminate\Contracts\View\View;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
use Modules\Stylist\View\ThemeViewResolver;
if (! function_exists('stylist')) {
function stylist(): Stylist
{
return app(Stylist::class);
}
}
if (! function_exists('stylist_html')) {
function stylist_html(): ThemeHtmlBuilder
{
return app(ThemeHtmlBuilder::class);
}
}
if (! function_exists('stylist_view')) {
function stylist_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->make($view, $data, $mergeData);
}
}
if (! function_exists('stylist_theme_view')) {
function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData);
}
}
if (! function_exists('stylist_frontend_view')) {
function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('stylist_backend_view')) {
function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('theme_asset')) {
function theme_asset(string $path): string
{
return app(UrlGenerator::class)->asset($path);
}
}
if (! function_exists('theme_switcher')) {
function theme_switcher(): HtmlString
{
return stylist_html()->switcher();
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
use Modules\Stylist\Theme\UrlGenerator;
final class ThemeHtmlBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $url, private readonly Loader $loader) {}
public function stylesheet(?string $path = null): HtmlString
{
if ($path !== null) {
$theme = $this->stylist->current();
if (! $theme->has($path)) {
return new HtmlString('');
}
return new HtmlString('<link rel="stylesheet" href="'.e($this->url->forTheme($theme, $path)).'">');
}
$tags = [];
foreach (array_reverse($this->themeChain()) as $theme) {
$stylesheet = $theme->stylesheet();
if ($stylesheet !== '' && $theme->has($stylesheet)) {
$tags[] = '<link rel="stylesheet" href="'.e($this->url->forTheme($theme, $stylesheet)).'">';
}
}
return new HtmlString(implode('', $tags));
}
public function script(?string $path = null): HtmlString
{
if ($path !== null) {
$theme = $this->stylist->current();
if (! $theme->has($path)) {
return new HtmlString('');
}
return new HtmlString('<script src="'.e($this->url->forTheme($theme, $path)).'" defer></script>');
}
$tags = [];
foreach (array_reverse($this->themeChain()) as $theme) {
$script = $theme->script();
if ($script !== '' && $theme->has($script)) {
$tags[] = '<script src="'.e($this->url->forTheme($theme, $script)).'" defer></script>';
}
}
return new HtmlString(implode('', $tags));
}
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-appearance="'.$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(){d.dataset.theme=k;if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.appearance=dark?'dark':'light';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>');
}
/** @return array<int, Theme> */
private function themeChain(): array
{
$chain = [];
$theme = $this->stylist->current();
$seen = [];
while (! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$chain[] = $theme;
$parent = $theme->parent();
if (! $parent) {
break;
}
try {
$theme = $this->loader->find($parent);
} catch (\Throwable) {
break;
}
}
return $chain;
}
private function declarations(array $variables): string
{
return collect($variables)
->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value)))
->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';')
->implode('');
}
private function selector(string $value): string
{
return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default';
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
final class ThemeSeoBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {}
public function render(array $overrides = []): HtmlString
{
$theme = $this->stylist->current();
$seo = array_replace($theme->seo(), $overrides);
$settings = $this->stylist->settings();
$title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name());
$description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description());
$image = $seo['image'] ?? $theme->preview();
if ($image && $theme->has($image)) {
$image = $this->urls->asset($image);
}
$tags = ['<title>'.e($title).'</title>'];
if ($description !== '') {
$tags[] = '<meta name="description" content="'.e($description).'">';
}
foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) {
if ($content === '') {
continue;
}$attribute = str_starts_with($property, 'og:') ? 'property' : 'name';
$tags[] = '<meta '.$attribute.'="'.e($property).'" content="'.e($content).'">';
}
if ($image) {
$tags[] = '<meta property="og:image" content="'.e($image).'">';
$tags[] = '<meta name="twitter:image" content="'.e($image).'">';
}
if (isset($seo['json_ld']) && is_array($seo['json_ld'])) {
$tags[] = '<script type="application/ld+json">'.json_encode($seo['json_ld'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).'</script>';
}
return new HtmlString(implode("\n", $tags));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Routing\Controller;
use Modules\Settings\Services\SettingsRepository;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
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', 'frontend'), 'site'),
'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'backend'), 'site'),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Modules\Stylist\Theme\Loader;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ThemeAssetController extends Controller
{
private const ALLOWED_ROOTS = ['css', 'js', 'images', 'fonts', 'icons', 'assets'];
private const ALLOWED_EXTENSIONS = [
'css', 'js', 'mjs', 'map', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif',
'ico', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'json', 'xml', 'txt', 'webmanifest',
];
public function __invoke(string $theme, string $path, Loader $loader): BinaryFileResponse|Response
{
abort_if(str_contains($path, "\0"), 404);
$normalised = str_replace('\\', '/', ltrim($path, '/'));
$root = explode('/', $normalised, 2)[0] ?? '';
$extension = strtolower(pathinfo($normalised, PATHINFO_EXTENSION));
abort_unless(in_array($root, self::ALLOWED_ROOTS, true), 404);
abort_unless(in_array($extension, self::ALLOWED_EXTENSIONS, true), 404);
$loadedTheme = $loader->find($theme);
$themeRoot = realpath($loadedTheme->path());
abort_unless($themeRoot !== false, 404);
$asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised);
abort_unless(
$asset !== false
&& str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR)
&& is_file($asset),
404
);
$mime = match ($extension) {
'css' => 'text/css; charset=UTF-8',
'js', 'mjs' => 'application/javascript; charset=UTF-8',
'svg' => 'image/svg+xml',
'json', 'map', 'webmanifest' => 'application/json',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'otf' => 'font/otf',
default => mime_content_type($asset) ?: 'application/octet-stream',
};
return response()->file($asset, [
'Content-Type' => $mime,
'Cache-Control' => app()->isProduction()
? 'public, max-age=31536000, immutable'
: 'no-cache, private',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'",
]);
}
}
@@ -0,0 +1,87 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Settings\Services\SettingsRepository;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeOptimizer;
use Modules\Stylist\Services\ThemePackageManager;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\View\ThemeViewResolver;
final class ThemeManagementController extends Controller
{
public function activate(string $theme, Loader $loader, SettingsRepository $settings)
{
$item = $loader->find($theme);
abort_unless(in_array($item->type(), ['frontend', 'backend'], true), 422, 'Only frontend and backend themes can be activated.');
$settings->put($item->type().'_theme', $item->key(), 'site', 'string', true);
return back()->with('status', $item->name().' is now the active '.$item->type().' theme.');
}
public function preview(string $theme, Loader $loader, ThemeViewResolver $views)
{
$item = $loader->find($theme);
return $views->makeForTheme($item->key(), $item->entryView(), [
'themePreview' => true,
'previewTheme' => $item,
]);
}
public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings)
{
$item = $loader->find($theme);
$settings->save(
$item,
(array) $request->input('settings', []),
(string) $request->input('variant', 'light'),
$request->string('appearance')->toString() ?: null,
$request->string('accent')->toString() ?: null,
);
return back()->with('status', 'Theme settings saved.');
}
public function install(Request $request, ThemePackageManager $packages)
{
$request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]);
$theme = $packages->install($request->file('theme'));
return back()->with('status', $theme->name().' installed.');
}
public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
{
$item = $loader->find($theme);
$packages->uninstall($item);
return back()->with('status', $item->name().' uninstalled.');
}
public function export(string $theme, Loader $loader, ThemePackageManager $packages)
{
$item = $loader->find($theme);
$path = $packages->export($item);
return response()->download($path)->deleteFileAfterSend();
}
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
{
$item = $loader->find($theme);
$result = $optimizer->optimize($item);
return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
}
public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
{
return response()->json(['issues' => $validator->validate($loader->find($theme))]);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Livewire;
use Livewire\Component;
final class ThemePreview extends Component
{
public array $stats = [
'companies' => 12,
'warehouses' => 34,
'users' => 156,
];
public function render()
{
return stylist_view('livewire.theme-preview', [
'stats' => $this->stats,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Stylist\Models;
use Illuminate\Database\Eloquent\Model;
final class ThemeSetting extends Model
{
protected $table = 'stylist_theme_settings';
protected $fillable = ['theme_key', 'variant', 'settings'];
protected $casts = ['settings' => 'array'];
}
@@ -0,0 +1,17 @@
<?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'));
}
}
@@ -0,0 +1,376 @@
<?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\ModuleInfo;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
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());
});
// Register both selected presentation trees immediately, then register them again
// after every service provider has finished booting. Some package/module providers
// replace Laravel's base view locations during their own boot phase; the booted
// callback makes Stylist the final authority for ordinary view('...') resolution.
$this->registerSelectedThemeOverrides();
$this->app->booted(function (): void {
$this->registerSelectedThemeOverrides();
});
}
private function registerViews(): void
{
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
// Module-owned package views stay inside the module. Application overrides
// belong to resources/themes/{theme}/views/modules/stylist, which Stylist
// registers as the authoritative themed namespace. Never publish back into
// resources/views; the standard baseline intentionally has no legacy view tree.
$this->loadViewsFrom($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 registerSelectedThemeOverrides(): void
{
$stylist = $this->app->make(Stylist::class);
$current = $stylist->current();
$frontend = $stylist->frontend();
$backend = $stylist->backend();
// The active request context must win ordinary view resolution. Keep the
// opposite presentation tree available as a compatibility fallback, but
// never let frontend views shadow backend views on backend requests (or
// vice versa) simply because of provider registration order.
$orderedThemes = [$current, $frontend, $backend];
$chains = [];
$seenThemes = [];
foreach ($orderedThemes as $theme) {
if (isset($seenThemes[$theme->key()])) {
continue;
}
$seenThemes[$theme->key()] = true;
$chains[] = $this->themeChain($theme);
}
$keys = [];
foreach ($chains as $chain) {
foreach ($chain as $key) {
if (! in_array($key, $keys, true)) {
$keys[] = $key;
}
}
}
if (! in_array('common', $keys, true)) {
$keys[] = 'common';
}
// Laravel's starter kits resolve authenticated layouts through the
// x-layouts:: namespace. Once those views live inside the backend
// theme, Stylist must make that selected theme authoritative for the
// namespace as well as for ordinary view('...') lookups.
$this->registerLayoutCompatibilityNamespace($stylist->backend());
$finder = View::getFinder();
$themePaths = [];
foreach ($keys as $key) {
try {
$theme = $this->app->make(Loader::class)->find($key);
} catch (\Throwable) {
continue;
}
$viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views';
if (is_dir($viewsPath)) {
$themePaths[] = $viewsPath;
}
$this->registerThemeModuleNamespaces($key);
}
// FileViewFinder is the source of truth for ordinary view('...') resolution.
// Set the complete ordered path list explicitly instead of relying on provider
// boot order and incremental prependLocation() calls. Theme paths stay first;
// Laravel's normal view locations remain as the final fallback.
if (method_exists($finder, 'getPaths') && method_exists($finder, 'setPaths')) {
$existingPaths = $finder->getPaths();
$paths = array_values(array_unique(array_merge($themePaths, $existingPaths)));
$finder->setPaths($paths);
if (method_exists($finder, 'flush')) {
$finder->flush();
}
} else {
foreach (array_reverse($themePaths) as $viewsPath) {
if (method_exists($finder, 'prependLocation')) {
$finder->prependLocation($viewsPath);
} else {
View::addLocation($viewsPath);
}
}
}
}
/** @return array<int, string> */
private function themeChain(Theme $theme): array
{
$loader = $this->app->make(Loader::class);
$chain = [];
$seen = [];
while (! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$chain[] = $theme->key();
$parent = $theme->parent();
if (! $parent) {
break;
}
try {
$theme = $loader->find($parent);
} catch (\Throwable) {
break;
}
}
return $chain;
}
private function prependThemeViewTree(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$finder = View::getFinder();
if (method_exists($finder, 'prependLocation')) {
$finder->prependLocation($viewsPath);
} else {
View::addLocation($viewsPath);
}
$this->registerThemeModuleNamespaces($themeKey);
}
private function registerLayoutCompatibilityNamespace(Theme $theme): void
{
$candidates = [
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'layouts',
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'components'.DIRECTORY_SEPARATOR.'layouts',
];
foreach ($candidates as $layoutsPath) {
if (! is_dir($layoutsPath)) {
continue;
}
View::prependNamespace('layouts', $layoutsPath);
Blade::anonymousComponentPath($layoutsPath, 'layouts');
}
}
private function registerThemeModuleNamespaces(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$modulesPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules';
if (! is_dir($modulesPath)) {
return;
}
foreach (glob($modulesPath.'/*', GLOB_ONLYDIR) ?: [] as $moduleViewsPath) {
$namespace = strtolower(basename($moduleViewsPath));
View::prependNamespace($namespace, $moduleViewsPath);
$componentsPath = $moduleViewsPath.DIRECTORY_SEPARATOR.'components';
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, $namespace);
}
}
}
private function normalisePath(string $path): string
{
return rtrim(
str_replace('\\', '/', realpath($path) ?: $path),
'/'
);
}
/** @return array<int, string> */
private function themeRoots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
private function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
$this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
}
private function registerPublishing(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
], ['config', 'stylist-config']);
}
private function registerHelpers(): void
{
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
require_once $filename;
}
}
private function registerBlade(): void
{
Blade::directive('themeVariables', fn () => '<?php echo stylist_html()->variables(); ?>');
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
}
}
+183
View File
@@ -0,0 +1,183 @@
# Stylist Module
Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules.
## Theme layout
Each theme is self-contained:
```text
resources/themes/{theme}/
├── theme.json
├── css/theme.css
└── views/
├── layouts/app.blade.php
├── components/page-hero.blade.php
└── livewire/theme-preview.blade.php
```
The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`.
## Rendering themed views
Use the helper from controllers, Blade components, and Livewire components:
```php
return stylist_view('layouts.app');
```
It checks views in this order:
```text
stylist-theme-{active-theme}::{view}
stylist-theme-{default-theme}::{view}
stylist::{view}
```
## Livewire
Keep the PHP component class shared and put the template in each theme:
```text
Livewire/ThemePreview.php
resources/themes/default/views/livewire/theme-preview.blade.php
resources/themes/mpba/views/livewire/theme-preview.blade.php
```
The component renders with:
```php
return stylist_view('livewire.theme-preview', ['stats' => $this->stats]);
```
## Tenant assignment
Add or use these fields on `companies`:
```text
theme_key
brand_primary
brand_secondary
brand_accent
logo_path
favicon_path
theme_settings
```
Then assign:
```php
$company->update(['theme_key' => 'mpba']);
```
## Publishing
```bash
php artisan vendor:publish --tag=stylist-config
# or
php artisan stylist:publish-assets
Themes are not published from or stored inside the module. Install them directly under `resources/themes`.
```
## Blade helpers
```blade
<html data-theme="{{ stylist()->current()->key() }}">
@themeVariables
@themeStylesheet
</html>
```
## Self-contained application themes
Application themes live in `resources/themes/{theme}` and own their runtime assets:
```text
resources/themes/example/
├── theme.json
├── css/theme.css
├── js/app.js
└── views/home.blade.php
```
Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs.
## Appearance and colour variants
Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `<head>` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page.
## Theme manifest compatibility
Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare:
```json
{
"manifest_version": 1,
"key": "design-gallery",
"name": "Design Gallery"
}
```
`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery.
Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist:
```json
{
"appearances": ["light", "dark", "system"],
"accents": ["blue", "neutral"],
"default_appearance": "system",
"default_accent": "neutral"
}
```
Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls.
Additional boolean capabilities may be declared explicitly:
```json
{
"capabilities": {
"marketplace": true
}
}
```
## Validate a theme
Validate an installed theme key, unpacked theme directory, or ZIP package:
```bash
php artisan stylist:validate design-gallery
php artisan stylist:validate resources/themes/design-gallery
php artisan stylist:validate ~/Downloads/design-gallery.zip
```
The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found.
## Theme inheritance and diagnostics
Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views:
```json
{
"name": "Dashboard",
"slug": "admin",
"extends": "common"
}
```
Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views.
Use these maintenance commands:
```bash
php artisan stylist:inspect
php artisan stylist:duplicates
php artisan stylist:doctor
php artisan stylist:create-theme customer --parent=common --name="Customer Portal"
```
`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task.
@@ -0,0 +1,5 @@
[data-theme] {
--brand-primary: var(--brand-primary, #2563eb);
--brand-secondary: var(--brand-secondary, #0f172a);
--brand-accent: var(--brand-accent, #22c55e);
}
@@ -0,0 +1 @@
import '../css/app.css';
@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Stylist',
];
+16
View File
@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\ThemeAssetController;
/*
| Public theme assets
| --------------------------------------------------------------------------
| Themes are stored beneath resources/themes, which is not web-accessible.
| This route safely streams allow-listed assets through Laravel at the URL
| expected by generated themes: /themes/{theme}/{path}.
*/
Route::get('/themes/{theme}/{path}', ThemeAssetController::class)
->where('theme', '[A-Za-z0-9_-]+')
->where('path', '.*')
->name('stylist.theme.asset');
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\StylistController;
use Modules\Stylist\Http\Controllers\ThemeManagementController;
Route::middleware(['auth', 'verified'])->group(function (): void {
Route::get('/', StylistController::class)->name('index');
Route::post('/install', [ThemeManagementController::class, 'install'])->name('install');
Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings');
Route::post('/{theme}/activate', [ThemeManagementController::class, 'activate'])->name('activate');
Route::get('/{theme}/preview', [ThemeManagementController::class, 'preview'])->name('preview');
Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall');
Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export');
Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize');
Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility');
});
@@ -0,0 +1,102 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Blog\Models\Blog;
use Modules\Faq\Models\Faq;
use Modules\News\Models\News;
use Modules\Products\Models\Product;
use Modules\Team\Models\TeamMember;
use Modules\Testimonials\Models\Testimonial;
use Throwable;
final class FrontendContent
{
/** @return array<string, mixed> */
public function homepage(array $settings): array
{
// Always provide source collections. Visibility is owned by page placements
// in the composable content system; the legacy theme still applies its
// existing show_* settings while no composition has been installed.
return [
'pricingPlans' => $this->pricingPlans(),
'testimonials' => $this->testimonials(),
'faqs' => $this->faqs(),
'teamMembers' => $this->team(),
'latestBlogPosts' => $this->blogPosts(),
'latestNewsArticles' => $this->newsArticles(),
];
}
private function pricingPlans(): Collection
{
return $this->query('products', fn () => Product::query()
->where('is_active', true)
->where('product_type', 'plan')
->orderBy('sort_order')
->orderBy('id')
->limit(6)
->get());
}
private function testimonials(): Collection
{
return $this->query('testimonials', fn () => Testimonial::query()
->where('enabled', true)
->latest('id')
->limit(6)
->get());
}
private function faqs(): Collection
{
return $this->query('faqs', fn () => Faq::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(12)
->get());
}
private function team(): Collection
{
return $this->query('team_members', fn () => TeamMember::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(8)
->get());
}
private function blogPosts(): Collection
{
return $this->query('blog_posts', fn () => Blog::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function newsArticles(): Collection
{
return $this->query('news_articles', fn () => News::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function query(string $table, callable $callback): Collection
{
try {
return Schema::hasTable($table) ? $callback() : collect();
} catch (Throwable) {
return collect();
}
}
private function enabled(array $settings, string $key, bool $default): bool
{
return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Modules\Stylist\Services;
use App\Models\SiteSetting;
use Illuminate\Support\Facades\Auth;
final class TenantThemeResolver
{
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;
/*
* The legacy company theme_key is a public-facing tenant theme.
* It must never replace the application's backend/admin theme.
*/
if ($context === 'frontend' && $company && filled($company->{$column} ?? null)) {
return (string) $company->{$column};
}
$settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme';
$default = $context === 'frontend'
? config('stylist.default_frontend_theme', 'frontend')
: config('stylist.default_backend_theme', config('stylist.default_theme', 'backend'));
try {
return (string) SiteSetting::valueOf($settingKey, $default);
} catch (\Throwable) {
return (string) $default;
}
}
public function context(): string
{
if (app()->runningInConsole()) {
return 'backend';
}
$request = request();
return $request->is(
'dashboard',
'dashboard/*',
'settings',
'settings/*',
'admin',
'admin/*',
'helpdesk',
'helpdesk/*',
'login',
'register',
'forgot-password',
'reset-password',
'reset-password/*',
'confirm-password',
'user/confirm-password',
'two-factor-challenge',
'verify-email',
'verify-email/*',
'email/verify',
'email/verify/*',
) || $request->routeIs(
'dashboard',
'profile.edit',
'appearance.edit',
'security.edit',
'admin.*',
'helpdesk.*',
'login',
'register',
'password.*',
'verification.*',
'two-factor.*',
)
? 'backend'
: 'frontend';
}
public function variables(): array
{
$variables = config('stylist.fallback_variables', []);
$company = Auth::user()?->company ?? null;
foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) {
if ($company && filled($company->{$column} ?? null)) {
$variables[$variable] = (string) $company->{$column};
}
}
return $variables;
}
}
@@ -0,0 +1,72 @@
<?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();
$missingAlt = 0;
if (preg_match_all('/<img\\b(?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*>/is', $content, $images)) {
foreach ($images[0] as $image) {
if (! preg_match('/\\balt\\s*=\\s*(?:"[^"]*"|\'[^\']*\')/is', $image)) {
$missingAlt++;
}
}
}
if ($missingAlt > 0) {
$issues[] = [
'severity' => 'error',
'message' => $missingAlt.' 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,
];
}
$unnamedButtons = 0;
if (preg_match_all('/<button\\b((?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*)>(.*?)<\\/button>/is', $content, $buttons, PREG_SET_ORDER)) {
foreach ($buttons as $button) {
$attributes = $button[1];
$body = $button[2];
$hasAccessibleAttribute = preg_match('/\\b(?:aria-label|aria-labelledby|title)\\s*=\\s*(?:"[^"]+"|\'[^\']+\')/is', $attributes) === 1;
$visibleText = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5));
if (! $hasAccessibleAttribute && $visibleText === '') {
$unnamedButtons++;
}
}
}
if ($unnamedButtons > 0) {
$issues[] = [
'severity' => 'warning',
'message' => $unnamedButtons.' 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\Settings\Services\SettingsRepository;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
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,83 @@
<?php
namespace Modules\Stylist\Support;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Modules\Menus\Models\Menu;
use Throwable;
final class FrontendNavigation
{
/** @return array<int, array<string, mixed>> */
public function primary(): array
{
return $this->location('primary', $this->fallbackPrimary());
}
/** @return array<int, array<string, mixed>> */
public function footer(): array
{
return $this->location('footer', $this->fallbackPrimary());
}
/** @param array<int, array<string, mixed>> $fallback
* @return array<int, array<string, mixed>>
*/
public function location(string $location, array $fallback = []): array
{
try {
if (! Schema::hasTable('menus')) {
return $fallback;
}
$menu = Menu::query()->where('enabled', true)->where('location', $location)->first();
if (! $menu || ! is_array($menu->items)) {
return $fallback;
}
$items = $this->normaliseItems($menu->items);
return $items === [] ? $fallback : $items;
} catch (Throwable) {
return $fallback;
}
}
/** @param array<int, mixed> $items
* @return array<int, array<string, mixed>>
*/
private function normaliseItems(array $items): array
{
return collect($items)
->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL))
->map(function (array $item): ?array {
$label = trim((string) ($item['label'] ?? ''));
$url = trim((string) ($item['url'] ?? ''));
if ($label === '' || $url === '') {
return null;
}
return [
'label' => $label,
'url' => $url,
'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null,
'children' => $this->normaliseItems((array) ($item['children'] ?? [])),
];
})->filter()->values()->all();
}
/** @return array<int, array<string, mixed>> */
private function fallbackPrimary(): array
{
$items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]];
if (Route::has('blog.public.index')) {
$items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
}
if (Route::has('news.public.index')) {
$items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
}
return $items;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Stylist\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Stylist';
}
public static function nameLower(): string
{
return 'stylist';
}
}
@@ -0,0 +1,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-backend::dashboard'));
$this->assertTrue(view()->exists('stylist-theme-backend::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' => 'backend']);
$this->assertSame('backend', app(Stylist::class)->current()->key());
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Http\Request;
use Modules\Stylist\Services\TenantThemeResolver;
use Tests\TestCase;
final class TenantThemeResolverTest extends TestCase
{
public function test_dashboard_request_uses_backend_context(): void
{
$request = Request::create('/dashboard', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_settings_request_uses_backend_context(): void
{
$request = Request::create('/settings/profile', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_admin_request_uses_backend_context(): void
{
$request = Request::create('/admin', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_admin_helpdesk_request_uses_backend_context(): void
{
$request = Request::create('/admin/helpdesk', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_authentication_requests_use_backend_context(): void
{
foreach (['/login', '/register', '/forgot-password', '/reset-password/token', '/user/confirm-password', '/two-factor-challenge', '/email/verify'] as $uri) {
$request = Request::create($uri, 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context(), $uri);
}
}
}
@@ -0,0 +1,93 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Theme\Theme;
use Tests\TestCase;
final class ThemeAccessibilityValidatorTest extends TestCase
{
private string $themePath;
protected function setUp(): void
{
parent::setUp();
$this->themePath = storage_path('framework/testing/stylist-accessibility-'.uniqid());
mkdir($this->themePath.'/views', 0777, true);
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themePath);
parent::tearDown();
}
public function test_blade_object_access_does_not_break_image_alt_detection(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<img src="{{ $post->featuredImageUrl() }}" alt="{{ $post->title }}">'
);
$this->assertSame([], $this->validate());
}
public function test_image_without_alt_is_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<img src="{{ $post->featuredImageUrl() }}">'
);
$issues = $this->validate();
$this->assertCount(1, $issues);
$this->assertSame('error', $issues[0]['severity']);
$this->assertSame('1 image(s) missing alt text.', $issues[0]['message']);
}
public function test_aria_labelled_icon_button_is_not_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="button" aria-label="Open navigation"><span></span><span></span></button>'
);
$this->assertSame([], $this->validate());
}
public function test_button_with_visible_text_is_not_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="submit"><span>Save</span></button>'
);
$this->assertSame([], $this->validate());
}
public function test_empty_unlabelled_button_is_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="button"><span></span></button>'
);
$issues = $this->validate();
$this->assertCount(1, $issues);
$this->assertSame('warning', $issues[0]['severity']);
$this->assertSame('1 button(s) may lack an accessible name.', $issues[0]['message']);
}
/** @return array<int, array{severity:string,message:string,file:string}> */
private function validate(): array
{
$theme = new Theme('test', 'Test', $this->themePath);
return app(ThemeAccessibilityValidator::class)->validate($theme);
}
}
@@ -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' => 'frontend',
'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' => 'frontend',
'path' => 'images/example.jpg',
], false);
$this->assertSame('/themes/frontend/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' => 'frontend',
'path' => 'views/home.blade.php',
]))->assertNotFound();
}
}
@@ -0,0 +1,38 @@
<?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);
}
public function test_appearance_does_not_overwrite_theme_identity(): void
{
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
$this->assertStringContainsString('d.dataset.theme=k', $html);
$this->assertStringContainsString('d.dataset.appearance=dark?', $html);
$this->assertStringNotContainsString("d.dataset.theme=dark?'dark':'light'", $html);
}
public function test_appearance_variables_use_data_appearance_selector(): void
{
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
if (str_contains($html, 'data-appearance')) {
$this->assertStringNotContainsString(':root[data-theme="dark"]', $html);
} else {
$this->addToAssertionCount(1);
}
}
}
@@ -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,166 @@
<?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_bundled_default_themes_have_valid_manifests(): void
{
foreach (['backend', 'frontend'] as $key) {
$theme = app(Loader::class)->fromPath(resource_path("themes/{$key}"));
$this->assertSame($key, $theme->key());
}
}
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' => 'backend',
'stylist.themes_path' => resource_path('themes'),
]);
$candidates = app(ThemeViewResolver::class)->candidates('components.page-hero');
$this->assertContains('stylist-theme-backend::components.page-hero', $candidates);
$this->assertContains('stylist::components.page-hero', $candidates);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Tests\TestCase;
final class WelcomeThemeViewTest extends TestCase
{
public function test_bundled_frontend_theme_view_namespace_is_registered(): void
{
$this->assertTrue(view()->exists('stylist-theme-frontend::welcome'));
}
public function test_dashboard_view_is_owned_by_the_backend_theme(): void
{
$this->assertStringContainsString(
resource_path('themes/backend/views/dashboard.blade.php'),
view('dashboard')->getPath(),
);
}
public function test_home_route_renders_from_the_frontend_theme(): void
{
$response = $this->get('/');
$response->assertOk()->assertViewIs('welcome');
$this->assertStringContainsString(
resource_path('themes/frontend/views/welcome.blade.php'),
view('welcome')->getPath(),
);
}
public function test_application_views_exist_only_inside_themes(): void
{
$this->assertDirectoryDoesNotExist(resource_path('views'));
$this->assertDirectoryDoesNotExist(resource_path('css'));
$this->assertDirectoryDoesNotExist(resource_path('js'));
}
public function test_backend_compiled_assets_are_owned_by_the_backend_theme(): void
{
$this->assertFileExists(resource_path('themes/backend/assets/css/app.css'));
$this->assertFileExists(resource_path('themes/backend/assets/js/passkeys.js'));
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class InvalidThemeManifestException extends RuntimeException
{
/** @param array<int, string> $errors */
public static function forPath(string $path, array $errors): self
{
return new self(sprintf(
'Invalid theme manifest at [%s]: %s',
rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json',
implode(' ', $errors),
));
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeJsonNotFoundException extends RuntimeException
{
public static function forPath(string $path): self
{
return new self("Theme manifest [{$path}/theme.json] was not found.");
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeNotFoundException extends RuntimeException
{
public static function forKey(string $key): self
{
return new self("Theme [{$key}] was not found.");
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Exceptions\ThemeJsonNotFoundException;
final class Json
{
public function __construct(private readonly Filesystem $files) {}
public function read(string $path): array
{
$manifest = rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json';
if (! $this->files->exists($manifest)) {
throw ThemeJsonNotFoundException::forPath($path);
}
return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Collection;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Loader
{
private ?Collection $themes = null;
public function __construct(
private readonly Filesystem $files,
private readonly Json $json,
private readonly ManifestValidator $validator,
) {}
public function all(): Collection
{
if ($this->themes !== null) {
return $this->themes;
}
$themes = collect();
foreach ($this->roots() as $root) {
if (! $this->files->isDirectory($root)) {
continue;
}
foreach ($this->files->directories($root) as $themePath) {
try {
$theme = $this->fromPath($themePath);
$themes->put($theme->key(), $theme);
} catch (\Throwable $exception) {
report($exception);
}
}
}
return $this->themes = $themes->sortKeys();
}
public function find(string $key): Theme
{
$theme = $this->all()->get($key);
if (! $theme) {
throw ThemeNotFoundException::forKey($key);
}
return $theme;
}
public function fromPath(string $path): Theme
{
$data = $this->validator->validate($this->json->read($path), $path);
return new Theme(
key: $data['key'],
name: $data['name'],
path: rtrim($path, DIRECTORY_SEPARATOR),
stylesheet: $data['stylesheet'],
script: $data['script'],
variables: $data['variables'],
meta: $data,
);
}
public function flush(): void
{
$this->themes = null;
}
/** @return array<int, string> */
public function roots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
final class ManifestValidator
{
/** @return array<string, mixed> */
public function validate(array $manifest, string $path): array
{
$errors = [];
$manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1;
if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) {
$manifestVersion = (int) $manifestVersion;
}
if (! is_int($manifestVersion) || $manifestVersion < 1) {
$errors[] = 'The manifest_version must be a positive integer.';
}
$key = $manifest['key'] ?? $manifest['slug'] ?? basename($path);
if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.';
}
if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) {
$errors[] = 'The name must be a non-empty string.';
}
if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) {
$errors[] = 'The version must use semantic versioning, for example 1.2.0.';
}
if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) {
$errors[] = 'The type must be frontend, backend, or common.';
}
$parent = $manifest['extends'] ?? $manifest['parent'] ?? null;
if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) {
$errors[] = 'The extends/parent value must be a valid theme key.';
}
foreach (['stylesheet', 'script', 'preview'] as $field) {
if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) {
$errors[] = "The {$field} value must be a safe relative path.";
}
}
foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) {
if (isset($manifest[$field]) && ! is_array($manifest[$field])) {
$errors[] = "The {$field} value must be an object or array.";
}
}
$variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false);
$appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors);
$accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors);
$capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors);
$capabilities['appearances'] = $appearances !== [];
$capabilities['accents'] = $accents !== [];
$capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp');
$defaultAppearance = $manifest['default_appearance'] ?? null;
if ($defaultAppearance !== null) {
if (! is_string($defaultAppearance) || $defaultAppearance === '') {
$errors[] = 'The default_appearance value must be a non-empty string.';
} elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) {
$errors[] = 'The default_appearance value must be declared in appearances.';
}
}
$defaultAccent = $manifest['default_accent'] ?? null;
if ($defaultAccent !== null) {
if (! is_string($defaultAccent) || $defaultAccent === '') {
$errors[] = 'The default_accent value must be a non-empty string.';
} elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) {
$errors[] = 'The default_accent value must be declared in accents.';
}
}
if ($errors !== []) {
throw InvalidThemeManifestException::forPath($path, $errors);
}
$manifest['manifest_version'] = $manifestVersion;
unset($manifest['schema_version']);
$manifest['key'] = $key;
$manifest['slug'] = $key;
$manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString();
$manifest['extends'] = $parent;
$manifest['version'] = (string) ($manifest['version'] ?? '1.0.0');
$manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home');
$manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css';
$manifest['script'] = $manifest['script'] ?? 'js/app.js';
$manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [];
$manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : [];
$manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : [];
$manifest['capabilities'] = $capabilities;
$manifest['variants'] = $variants;
$manifest['appearances'] = $appearances;
$manifest['accents'] = $accents;
$manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : [];
$manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : [];
$manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null);
return $manifest;
}
/**
* Compact lists such as ["light", "dark"] are accepted and converted to
* keyed empty definitions. Object definitions remain unchanged.
*
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, array<string, mixed>>
*/
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
{
if ($value === []) {
return [];
}
if (array_is_list($value)) {
if (! $allowCompact && $field === 'variants') {
// Older Theme Builder Phase 1 builds incorrectly wrote accent names
// to variants. Ignore that list while preserving object-style variants.
return [];
}
$normalised = [];
foreach ($value as $name) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
$errors[] = "Every {$field} entry must be a valid lowercase key.";
continue;
}
$normalised[$name] = [];
}
return $normalised;
}
$normalised = [];
foreach ($value as $name => $definition) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
$errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
continue;
}
$normalised[$name] = $definition;
}
return $normalised;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, bool>
*/
private function normaliseCapabilities(array $value, array &$errors): array
{
$normalised = [];
foreach ($value as $name => $enabled) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
$errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
continue;
}
$normalised[$name] = $enabled;
}
return $normalised;
}
private function isSafeRelativePath(mixed $path): bool
{
return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0");
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Services\TenantThemeResolver;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Stylist
{
public function __construct(private readonly Loader $loader, private readonly TenantThemeResolver $resolver, private readonly ThemeSettings $settings) {}
public function current(?string $context = null): Theme
{
$requested = $this->resolver->resolve($context);
try {
return $this->loader->find($requested);
} catch (ThemeNotFoundException) {
$fallback = $context === 'frontend'
? (string) config('stylist.default_frontend_theme', 'frontend')
: (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'backend'));
if ($requested !== $fallback) {
return $this->loader->find($fallback);
}
throw ThemeNotFoundException::forKey($requested);
}
}
public function frontend(): Theme
{
return $this->current('frontend');
}
public function backend(): Theme
{
return $this->current('backend');
}
public function variables(): array
{
$theme = $this->current();
$variant = $this->settings->variant($theme);
$variantVars = (array) data_get($theme->variants(), $variant.'.variables', []);
$appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []);
$accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []);
$settingVars = collect($this->settings->get($theme))
->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--'))
->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value])
->all();
return array_replace(
(array) config('stylist.fallback_variables', []),
$this->inheritedVariables($theme),
$variantVars,
$appearanceVars,
$accentVars,
$settingVars,
$this->resolver->variables(),
);
}
public function settings(): array
{
return $this->settings->get($this->current());
}
public function variant(): string
{
return $this->settings->variant($this->current());
}
public function appearance(): string
{
return $this->settings->appearance($this->current());
}
public function accent(): string
{
return $this->settings->accent($this->current());
}
public function all()
{
return $this->loader->all();
}
private function inheritedVariables(Theme $theme, array $seen = []): array
{
if (in_array($theme->key(), $seen, true)) {
return [];
}
$seen[] = $theme->key();
$parent = [];
if ($theme->parent()) {
try {
$parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
} catch (ThemeNotFoundException) {
}
}
return array_replace($parent, $theme->variables());
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Modules\Stylist\Theme;
final class Theme
{
public function __construct(
private readonly string $key,
private readonly string $name,
private readonly string $path,
private readonly string $stylesheet = 'css/theme.css',
private readonly string $script = 'js/app.js',
private readonly array $variables = [],
private readonly array $meta = [],
) {}
public function key(): string
{
return $this->key;
}
public function name(): string
{
return $this->name;
}
public function path(): string
{
return $this->path;
}
public function stylesheet(): string
{
return $this->stylesheet;
}
public function script(): string
{
return $this->script;
}
public function variables(): array
{
return $this->variables;
}
public function meta(): array
{
return $this->meta;
}
public function manifestVersion(): int
{
return (int) ($this->meta['manifest_version'] ?? 1);
}
public function version(): string
{
return (string) ($this->meta['version'] ?? '1.0.0');
}
public function description(): string
{
return (string) ($this->meta['description'] ?? '');
}
public function type(): string
{
return (string) ($this->meta['type'] ?? 'frontend');
}
public function author(): string
{
return (string) ($this->meta['author'] ?? '');
}
public function parent(): ?string
{
$parent = $this->meta['extends'] ?? null;
return is_string($parent) && $parent !== '' ? $parent : null;
}
public function preview(): ?string
{
$preview = $this->meta['preview'] ?? null;
return is_string($preview) && $preview !== '' ? $preview : null;
}
public function variants(): array
{
return (array) ($this->meta['variants'] ?? []);
}
public function appearances(): array
{
return (array) ($this->meta['appearances'] ?? []);
}
public function accents(): array
{
return (array) ($this->meta['accents'] ?? []);
}
public function defaultAppearance(): string
{
return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? '');
}
public function defaultAccent(): string
{
return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? '');
}
public function settingsSchema(): array
{
return (array) ($this->meta['settings'] ?? []);
}
public function seo(): array
{
return (array) ($this->meta['seo'] ?? []);
}
public function entryView(): string
{
return (string) ($this->meta['entry_view'] ?? 'home');
}
public function capabilities(): array
{
return (array) ($this->meta['capabilities'] ?? []);
}
public function hasCapability(string $feature): bool
{
return (bool) ($this->capabilities()[$feature] ?? false);
}
public function supports(string $feature): bool
{
return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature));
}
public function has(string $relativePath): bool
{
return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'));
}
public function absolutePath(string $relativePath): string
{
return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/');
}
/** @deprecated Theme assets are served through UrlGenerator. */
public function assetsPath(): string
{
return 'stylist/theme-assets/'.$this->key;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Stylist\Theme;
final class UrlGenerator
{
public function __construct(private readonly Stylist $stylist) {}
public function asset(string $path): string
{
return $this->forTheme($this->stylist->current(), $path);
}
public function forTheme(Theme|string $theme, string $path): string
{
$key = $theme instanceof Theme ? $theme->key() : $theme;
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?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> */
/** @return array<int, string> */
public function themedCandidatesForTheme(string $theme, string $view): array
{
return array_values(array_filter(
$this->candidatesForTheme($theme, $view),
fn (string $candidate): bool => str_contains($candidate, '::'),
));
}
public function candidatesForTheme(string $theme, string $view): array
{
$view = ltrim($view, '.');
$cacheKey = $theme.'|'.$view;
if (isset($this->candidateCache[$cacheKey])) {
return $this->candidateCache[$cacheKey];
}
$keys = $this->inheritanceChain($theme);
$fallback = (string) config('stylist.default_theme', 'common');
if ($fallback !== '' && ! in_array($fallback, $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain($fallback));
} catch (ThemeNotFoundException) {
// A missing configured fallback is diagnosed by stylist:doctor.
}
}
if (! in_array('common', $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain('common'));
} catch (ThemeNotFoundException) {
// The module fallback below remains available.
}
}
$candidates = [];
foreach (array_values(array_unique($keys)) as $key) {
$this->registerNamespace($key);
$candidates[] = "stylist-theme-{$key}::{$view}";
$candidates[] = "stylist-module-theme-{$key}::{$view}";
}
$candidates[] = "stylist::{$view}";
$candidates[] = $view;
return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates));
}
public function flush(): void
{
$this->candidateCache = [];
}
/** @return array<int, string> */
private function inheritanceChain(string $theme): array
{
$keys = [];
$seen = [];
$key = $theme;
while ($key !== '') {
if (isset($seen[$key])) {
throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].');
}
$seen[$key] = true;
$loaded = $this->loader->find($key);
$keys[] = $loaded->key();
$key = $loaded->parent() ?? '';
}
return $keys;
}
private function registerNamespace(string $theme): void
{
try {
$loaded = $this->loader->find($theme);
} catch (ThemeNotFoundException) {
return;
}
$viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$this->views->addNamespace("stylist-theme-{$theme}", $viewsPath);
$this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath);
}
}
+24
View File
@@ -0,0 +1,24 @@
latest: 1.1.0
releases:
- version: 1.1.0
date: 2026-07-08
changes:
- Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views.
- Added ThemeViewResolver and stylist_view() fallback helper.
- Added default and MPBA example themes.
- Added theme publishing support.
- Updated Vite inputs to include theme CSS files.
- version: 1.0.0
date: 2026-07-08
changes:
- Initial Laravel 13 Stylist module.
- version: 1.1.0
date: 2026-07-23
changes:
- Added optional manifest_version/schema_version normalisation.
- Added capability normalisation for appearances, accents, previews and future features.
- Added stylist:validate for installed themes, directories and ZIP packages.
- Added complete package validation during installation.
- Added dynamic view namespace registration after installing a theme.
- Added manifest compatibility and install-render regression coverage.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mpba/stylist-module",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"type": "laravel-module",
"require": {
"php": "^8.3",
"illuminate/support": "^13.0",
"illuminate/view": "^13.0",
"illuminate/filesystem": "^13.0",
"illuminate/console": "^13.0"
},
"autoload": {
"psr-4": {
"Modules\\Stylist\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Stylist\\Tests\\": "Tests/"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Stylist",
"alias": "stylist",
"version": "1.1.0",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"keywords": [
"laravel",
"modules",
"themes",
"tenancy",
"styling"
],
"priority": 0,
"providers": [
"Modules\\Stylist\\Providers\\StylistServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+15
View File
@@ -0,0 +1,15 @@
{
"scripts": {
"dev": "vite",
"build": "vite build",
"stylist:dev": "vite --config Modules/Stylist/vite.config.js",
"stylist:build": "vite build --config Modules/Stylist/vite.config.js",
"matches:dev": "vite --config Modules/Matches/vite.config.js",
"matches:build": "vite build --config Modules/Matches/vite.config.js",
"modules:build": "npm run stylist:build && npm run matches:build",
"build:all": "npm run build && npm run modules:build"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: [
'Modules/Stylist/Resources/assets/css/app.css',
'Modules/Stylist/Resources/assets/js/app.js',
],
refresh: [
'Modules/Stylist/Resources/views/**',
'resources/themes/**/views/**',
'Modules/Stylist/App/**',
'Modules/Stylist/routes/**',
],
buildDirectory: 'build/stylist',
}),
tailwindcss(),
],
});