From bfa4a2312cd84f6acf0ed0485fc6422b53b33c10 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 20 Oct 2026 09:57:43 +0100 Subject: [PATCH] download fixes --- Http/Controllers/Admin/CmsPageController.php | 45 ---- .../Admin/SeoSettingsController.php | 50 ---- Http/Controllers/SeoController.php | 53 ----- Models/CmsPage.php | 22 -- Models/SiteSetting.php | 32 --- modules/Stylist/Config/config.php | 29 +++ .../Stylist/Console/CreateThemeCommand.php | 64 ++++++ .../Console/FindDuplicateViewsCommand.php | 40 ++++ .../Stylist/Console/InspectThemesCommand.php | 28 +++ .../Stylist/Console/PublishAssetsCommand.php | 34 +++ .../Stylist/Console/ThemeDoctorCommand.php | 38 ++++ .../Stylist/Console/ValidateThemeCommand.php | 108 +++++++++ ..._add_stylist_fields_to_companies_table.php | 56 +++++ ...01_create_stylist_theme_settings_table.php | 24 ++ .../Stylist/Diagnostics/ThemeInspector.php | 147 ++++++++++++ modules/Stylist/Facades/StylistFacade.php | 13 ++ modules/Stylist/Facades/ThemeFacade.php | 13 ++ modules/Stylist/Helpers/helpers.php | 64 ++++++ modules/Stylist/Html/ThemeHtmlBuilder.php | 99 ++++++++ modules/Stylist/Html/ThemeSeoBuilder.php | 44 ++++ .../Http/Controllers/StylistController.php | 23 ++ .../Http/Controllers/ThemeAssetController.php | 64 ++++++ .../Controllers/ThemeManagementController.php | 87 +++++++ modules/Stylist/Livewire/ThemePreview.php | 21 ++ modules/Stylist/Models/ThemeSetting.php | 14 ++ .../Providers/RouteServiceProvider.php | 22 ++ .../Providers/StylistServiceProvider.php | 215 ++++++++++++++++++ modules/Stylist/README.md | 183 +++++++++++++++ modules/Stylist/Resources/assets/css/app.css | 5 + modules/Stylist/Resources/assets/js/app.js | 1 + .../Stylist/Resources/lang/en/messages.php | 5 + .../Stylist/Resources/views/index.blade.php | 78 +++++++ modules/Stylist/Routes/assets.php | 16 ++ modules/Stylist/Routes/web.php | 17 ++ modules/Stylist/Services/FrontendContent.php | 102 +++++++++ .../Stylist/Services/TenantThemeResolver.php | 65 ++++++ .../Services/ThemeAccessibilityValidator.php | 32 +++ modules/Stylist/Services/ThemeOptimizer.php | 57 +++++ .../Stylist/Services/ThemePackageManager.php | 98 ++++++++ modules/Stylist/Services/ThemeSettings.php | 97 ++++++++ modules/Stylist/Services/ThemeValidator.php | 74 ++++++ .../Stylist/Support/FrontendNavigation.php | 76 +++++++ modules/Stylist/Support/ModuleInfo.php | 16 ++ .../Feature/CommonApplicationViewsTest.php | 56 +++++ .../Feature/LegacyThemeNamespaceAliasTest.php | 14 ++ .../Tests/Feature/StylistModuleTest.php | 21 ++ .../Feature/ThemeAssetControllerTest.php | 41 ++++ .../Tests/Feature/ThemeHtmlBuilderTest.php | 18 ++ .../Tests/Feature/ThemeInheritanceTest.php | 95 ++++++++ .../Tests/Feature/ThemeManifestTest.php | 157 +++++++++++++ .../Feature/ThemePackageRoundTripTest.php | 79 +++++++ .../Tests/Feature/ThemeViewResolverTest.php | 22 ++ .../Tests/Feature/WelcomeThemeViewTest.php | 21 ++ .../InvalidThemeManifestException.php | 18 ++ .../Exceptions/ThemeJsonNotFoundException.php | 13 ++ .../Exceptions/ThemeNotFoundException.php | 13 ++ modules/Stylist/Theme/Json.php | 22 ++ modules/Stylist/Theme/Loader.php | 83 +++++++ modules/Stylist/Theme/ManifestValidator.php | 179 +++++++++++++++ modules/Stylist/Theme/Stylist.php | 103 +++++++++ modules/Stylist/Theme/Theme.php | 161 +++++++++++++ modules/Stylist/Theme/UrlGenerator.php | 20 ++ modules/Stylist/View/ThemeViewResolver.php | 130 +++++++++++ modules/Stylist/changelog.yaml | 24 ++ modules/Stylist/composer.json | 22 ++ modules/Stylist/module.json | 20 ++ modules/Stylist/package.json | 15 ++ modules/Stylist/vite.config.js | 25 ++ 68 files changed, 3541 insertions(+), 202 deletions(-) delete mode 100644 Http/Controllers/Admin/CmsPageController.php delete mode 100644 Http/Controllers/Admin/SeoSettingsController.php delete mode 100644 Http/Controllers/SeoController.php delete mode 100644 Models/CmsPage.php delete mode 100644 Models/SiteSetting.php create mode 100644 modules/Stylist/Config/config.php create mode 100644 modules/Stylist/Console/CreateThemeCommand.php create mode 100644 modules/Stylist/Console/FindDuplicateViewsCommand.php create mode 100644 modules/Stylist/Console/InspectThemesCommand.php create mode 100644 modules/Stylist/Console/PublishAssetsCommand.php create mode 100644 modules/Stylist/Console/ThemeDoctorCommand.php create mode 100644 modules/Stylist/Console/ValidateThemeCommand.php create mode 100644 modules/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php create mode 100644 modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php create mode 100644 modules/Stylist/Diagnostics/ThemeInspector.php create mode 100644 modules/Stylist/Facades/StylistFacade.php create mode 100644 modules/Stylist/Facades/ThemeFacade.php create mode 100644 modules/Stylist/Helpers/helpers.php create mode 100644 modules/Stylist/Html/ThemeHtmlBuilder.php create mode 100644 modules/Stylist/Html/ThemeSeoBuilder.php create mode 100644 modules/Stylist/Http/Controllers/StylistController.php create mode 100644 modules/Stylist/Http/Controllers/ThemeAssetController.php create mode 100644 modules/Stylist/Http/Controllers/ThemeManagementController.php create mode 100644 modules/Stylist/Livewire/ThemePreview.php create mode 100644 modules/Stylist/Models/ThemeSetting.php create mode 100644 modules/Stylist/Providers/RouteServiceProvider.php create mode 100644 modules/Stylist/Providers/StylistServiceProvider.php create mode 100644 modules/Stylist/README.md create mode 100644 modules/Stylist/Resources/assets/css/app.css create mode 100644 modules/Stylist/Resources/assets/js/app.js create mode 100644 modules/Stylist/Resources/lang/en/messages.php create mode 100644 modules/Stylist/Resources/views/index.blade.php create mode 100644 modules/Stylist/Routes/assets.php create mode 100644 modules/Stylist/Routes/web.php create mode 100644 modules/Stylist/Services/FrontendContent.php create mode 100644 modules/Stylist/Services/TenantThemeResolver.php create mode 100644 modules/Stylist/Services/ThemeAccessibilityValidator.php create mode 100644 modules/Stylist/Services/ThemeOptimizer.php create mode 100644 modules/Stylist/Services/ThemePackageManager.php create mode 100644 modules/Stylist/Services/ThemeSettings.php create mode 100644 modules/Stylist/Services/ThemeValidator.php create mode 100644 modules/Stylist/Support/FrontendNavigation.php create mode 100644 modules/Stylist/Support/ModuleInfo.php create mode 100644 modules/Stylist/Tests/Feature/CommonApplicationViewsTest.php create mode 100644 modules/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php create mode 100644 modules/Stylist/Tests/Feature/StylistModuleTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemeAssetControllerTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemeInheritanceTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemeManifestTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php create mode 100644 modules/Stylist/Tests/Feature/ThemeViewResolverTest.php create mode 100644 modules/Stylist/Tests/Feature/WelcomeThemeViewTest.php create mode 100644 modules/Stylist/Theme/Exceptions/InvalidThemeManifestException.php create mode 100644 modules/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php create mode 100644 modules/Stylist/Theme/Exceptions/ThemeNotFoundException.php create mode 100644 modules/Stylist/Theme/Json.php create mode 100644 modules/Stylist/Theme/Loader.php create mode 100644 modules/Stylist/Theme/ManifestValidator.php create mode 100644 modules/Stylist/Theme/Stylist.php create mode 100644 modules/Stylist/Theme/Theme.php create mode 100644 modules/Stylist/Theme/UrlGenerator.php create mode 100644 modules/Stylist/View/ThemeViewResolver.php create mode 100644 modules/Stylist/changelog.yaml create mode 100644 modules/Stylist/composer.json create mode 100644 modules/Stylist/module.json create mode 100644 modules/Stylist/package.json create mode 100644 modules/Stylist/vite.config.js diff --git a/Http/Controllers/Admin/CmsPageController.php b/Http/Controllers/Admin/CmsPageController.php deleted file mode 100644 index e12651d..0000000 --- a/Http/Controllers/Admin/CmsPageController.php +++ /dev/null @@ -1,45 +0,0 @@ - CmsPage::latest()->paginate(25)]); } - public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } - public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } - public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } - public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } - public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } - - private function validated(Request $request, ?CmsPage $page = null): array - { - $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); - $data = $request->validate([ - 'title' => ['required','string','max:160'], - 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], - 'excerpt' => ['nullable','string','max:1000'], - 'body' => ['required','string'], - 'meta_title' => ['nullable','string','max:160'], - 'meta_description' => ['nullable','string','max:320'], - 'meta_keywords' => ['nullable','string','max:500'], - 'canonical_url' => ['nullable','url:http,https','max:2048'], - 'og_title' => ['nullable','string','max:160'], - 'og_description' => ['nullable','string','max:320'], - 'og_image' => ['nullable','url:http,https','max:2048'], - 'sitemap_priority' => ['required','numeric','min:0','max:1'], - 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], - ]); - $data['robots_index'] = $request->boolean('robots_index'); - $data['robots_follow'] = $request->boolean('robots_follow'); - $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); - $data['is_published'] = $request->boolean('is_published'); - $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; - return $data; - } -} diff --git a/Http/Controllers/Admin/SeoSettingsController.php b/Http/Controllers/Admin/SeoSettingsController.php deleted file mode 100644 index a7f920d..0000000 --- a/Http/Controllers/Admin/SeoSettingsController.php +++ /dev/null @@ -1,50 +0,0 @@ - collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), - 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), - ]); - } - - public function update(Request $request): RedirectResponse - { - $data = $request->validate([ - 'seo_site_name' => ['required', 'string', 'max:80'], - 'seo_title_suffix' => ['nullable', 'string', 'max:80'], - 'seo_default_title' => ['required', 'string', 'max:160'], - 'seo_default_description' => ['required', 'string', 'max:320'], - 'seo_default_keywords' => ['nullable', 'string', 'max:500'], - 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], - 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], - 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], - 'seo_organization_name' => ['nullable', 'string', 'max:120'], - 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], - 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], - ]); - - foreach (self::KEYS as $key) { - SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); - } - SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); - - return back()->with('status', 'SEO settings updated.'); - } -} diff --git a/Http/Controllers/SeoController.php b/Http/Controllers/SeoController.php deleted file mode 100644 index 59603f3..0000000 --- a/Http/Controllers/SeoController.php +++ /dev/null @@ -1,53 +0,0 @@ - 'home', 'priority' => '1.0', 'frequency' => 'weekly'], - ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], - ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], - ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], - ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], - ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], - ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], - ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], - ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], - ])->filter(fn (array $item) => Route::has($item['route']))->values(); - - $pages = CmsPage::query() - ->where('is_published', true) - ->where('include_in_sitemap', true) - ->where('robots_index', true) - ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) - ->orderBy('slug') - ->get(); - - return response() - ->view('seo.sitemap', compact('static', 'pages')) - ->header('Content-Type', 'application/xml; charset=UTF-8') - ->header('Cache-Control', 'public, max-age=3600'); - } - - public function robots(): Response - { - $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); - - $body = $indexable - ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" - : "User-agent: *\nDisallow: /\n"; - - return response($body, 200, [ - 'Content-Type' => 'text/plain; charset=UTF-8', - 'Cache-Control' => 'public, max-age=3600', - ]); - } -} diff --git a/Models/CmsPage.php b/Models/CmsPage.php deleted file mode 100644 index 3209346..0000000 --- a/Models/CmsPage.php +++ /dev/null @@ -1,22 +0,0 @@ - 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', - 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', - ]; - } -} diff --git a/Models/SiteSetting.php b/Models/SiteSetting.php deleted file mode 100644 index 872204e..0000000 --- a/Models/SiteSetting.php +++ /dev/null @@ -1,32 +0,0 @@ - static::query()->find($key)?->value ?? $default); - } - - public static function booleanFor(string $key, bool $default = false): bool - { - $value = static::valueFor($key); - return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - public static function put(string $key, ?string $value): void - { - static::query()->updateOrCreate(['key' => $key], ['value' => $value]); - Cache::forget('sonardesk.setting.'.$key); - } -} diff --git a/modules/Stylist/Config/config.php b/modules/Stylist/Config/config.php new file mode 100644 index 0000000..71b15d3 --- /dev/null +++ b/modules/Stylist/Config/config.php @@ -0,0 +1,29 @@ + env('STYLIST_DEFAULT_THEME', 'admin'), + 'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'admin'), + 'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'saasland'), + + /* + | Themes are application resources, never module resources. + | Every theme is self-contained and owns its views and assets. + */ + 'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')), + + 'tenant_theme_column' => 'theme_key', + 'tenant_brand_columns' => [ + 'brand_primary' => 'brand-primary', + 'brand_secondary' => 'brand-secondary', + 'brand_accent' => 'brand-accent', + ], + + 'fallback_variables' => [ + 'brand-primary' => '#2563eb', + 'brand-secondary' => '#0f172a', + 'brand-accent' => '#22c55e', + 'surface' => '#ffffff', + 'surface-muted' => '#f8fafc', + 'text-primary' => '#0f172a', + ], +]; diff --git a/modules/Stylist/Console/CreateThemeCommand.php b/modules/Stylist/Console/CreateThemeCommand.php new file mode 100644 index 0000000..c7e1dff --- /dev/null +++ b/modules/Stylist/Console/CreateThemeCommand.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/modules/Stylist/Console/FindDuplicateViewsCommand.php b/modules/Stylist/Console/FindDuplicateViewsCommand.php new file mode 100644 index 0000000..2304ca1 --- /dev/null +++ b/modules/Stylist/Console/FindDuplicateViewsCommand.php @@ -0,0 +1,40 @@ +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; + } +} diff --git a/modules/Stylist/Console/InspectThemesCommand.php b/modules/Stylist/Console/InspectThemesCommand.php new file mode 100644 index 0000000..0764a1f --- /dev/null +++ b/modules/Stylist/Console/InspectThemesCommand.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/modules/Stylist/Console/PublishAssetsCommand.php b/modules/Stylist/Console/PublishAssetsCommand.php new file mode 100644 index 0000000..ab661fa --- /dev/null +++ b/modules/Stylist/Console/PublishAssetsCommand.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/modules/Stylist/Console/ThemeDoctorCommand.php b/modules/Stylist/Console/ThemeDoctorCommand.php new file mode 100644 index 0000000..d59cb3d --- /dev/null +++ b/modules/Stylist/Console/ThemeDoctorCommand.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/modules/Stylist/Console/ValidateThemeCommand.php b/modules/Stylist/Console/ValidateThemeCommand.php new file mode 100644 index 0000000..5e21f93 --- /dev/null +++ b/modules/Stylist/Console/ValidateThemeCommand.php @@ -0,0 +1,108 @@ +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]; + } +} diff --git a/modules/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php b/modules/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php new file mode 100644 index 0000000..2396070 --- /dev/null +++ b/modules/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php @@ -0,0 +1,56 @@ +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); + } + } + }); + } +}; diff --git a/modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php b/modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php new file mode 100644 index 0000000..57b6955 --- /dev/null +++ b/modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php @@ -0,0 +1,24 @@ +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'); + } +}; diff --git a/modules/Stylist/Diagnostics/ThemeInspector.php b/modules/Stylist/Diagnostics/ThemeInspector.php new file mode 100644 index 0000000..371d543 --- /dev/null +++ b/modules/Stylist/Diagnostics/ThemeInspector.php @@ -0,0 +1,147 @@ +> */ + 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> */ + 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 */ + 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 */ + 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 $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; + } +} diff --git a/modules/Stylist/Facades/StylistFacade.php b/modules/Stylist/Facades/StylistFacade.php new file mode 100644 index 0000000..77d173e --- /dev/null +++ b/modules/Stylist/Facades/StylistFacade.php @@ -0,0 +1,13 @@ +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(); + } +} diff --git a/modules/Stylist/Html/ThemeHtmlBuilder.php b/modules/Stylist/Html/ThemeHtmlBuilder.php new file mode 100644 index 0000000..b7b3c16 --- /dev/null +++ b/modules/Stylist/Html/ThemeHtmlBuilder.php @@ -0,0 +1,99 @@ +stylist->current()->stylesheet(); + if (! $this->stylist->current()->has($path)) { + return new HtmlString(''); + } + + return new HtmlString(''); + } + + public function script(?string $path = null): HtmlString + { + $path ??= $this->stylist->current()->script(); + if (! $this->stylist->current()->has($path)) { + return new HtmlString(''); + } + + return new HtmlString(''); + } + + public function variables(): HtmlString + { + $theme = $this->stylist->current(); + $default = $this->declarations($this->stylist->variables()); + $rules = [':root{'.$default.'}']; + + foreach ($theme->appearances() as $name => $definition) { + $vars = (array) data_get($definition, 'variables', []); + if ($vars) { + $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + } + } + foreach ($theme->accents() as $name => $definition) { + $vars = (array) data_get($definition, 'variables', []); + if ($vars) { + $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + } + } + + if ($theme->appearances() === [] && $theme->accents() === []) { + return new HtmlString(''); + } + + $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 = <<(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.theme=dark?'dark':'light';d.dataset.appearance=a;d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})(); +JS; + + return new HtmlString(''.$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) => '')->implode(''); + $accentOptions = collect($accents)->map(fn ($name) => '')->implode(''); + $appearanceSelect = $appearances ? '' : ''; + $accentSelect = $accents ? '' : ''; + + return new HtmlString('
'.$appearanceSelect.$accentSelect.'
'); + } + + 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'; + } +} diff --git a/modules/Stylist/Html/ThemeSeoBuilder.php b/modules/Stylist/Html/ThemeSeoBuilder.php new file mode 100644 index 0000000..28d5840 --- /dev/null +++ b/modules/Stylist/Html/ThemeSeoBuilder.php @@ -0,0 +1,44 @@ +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 = [''.e($title).'']; + if ($description !== '') { + $tags[] = ''; + } + 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[] = ''; + } + if ($image) { + $tags[] = ''; + $tags[] = ''; + } + if (isset($seo['json_ld']) && is_array($seo['json_ld'])) { + $tags[] = ''; + } + + return new HtmlString(implode("\n", $tags)); + } +} diff --git a/modules/Stylist/Http/Controllers/StylistController.php b/modules/Stylist/Http/Controllers/StylistController.php new file mode 100644 index 0000000..219359e --- /dev/null +++ b/modules/Stylist/Http/Controllers/StylistController.php @@ -0,0 +1,23 @@ + $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(), + 'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls, + 'activeFrontendTheme' => $siteSettings->get('frontend_theme', config('stylist.default_frontend_theme', 'saasland'), 'site'), + 'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'admin'), 'site'), + ]); + } +} diff --git a/modules/Stylist/Http/Controllers/ThemeAssetController.php b/modules/Stylist/Http/Controllers/ThemeAssetController.php new file mode 100644 index 0000000..e7695d1 --- /dev/null +++ b/modules/Stylist/Http/Controllers/ThemeAssetController.php @@ -0,0 +1,64 @@ +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'", + ]); + } +} diff --git a/modules/Stylist/Http/Controllers/ThemeManagementController.php b/modules/Stylist/Http/Controllers/ThemeManagementController.php new file mode 100644 index 0000000..3c4f12f --- /dev/null +++ b/modules/Stylist/Http/Controllers/ThemeManagementController.php @@ -0,0 +1,87 @@ +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))]); + } +} diff --git a/modules/Stylist/Livewire/ThemePreview.php b/modules/Stylist/Livewire/ThemePreview.php new file mode 100644 index 0000000..5e8560d --- /dev/null +++ b/modules/Stylist/Livewire/ThemePreview.php @@ -0,0 +1,21 @@ + 12, + 'warehouses' => 34, + 'users' => 156, + ]; + + public function render() + { + return stylist_view('livewire.theme-preview', [ + 'stats' => $this->stats, + ]); + } +} diff --git a/modules/Stylist/Models/ThemeSetting.php b/modules/Stylist/Models/ThemeSetting.php new file mode 100644 index 0000000..fb01e6f --- /dev/null +++ b/modules/Stylist/Models/ThemeSetting.php @@ -0,0 +1,14 @@ + 'array']; +} diff --git a/modules/Stylist/Providers/RouteServiceProvider.php b/modules/Stylist/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..68adb78 --- /dev/null +++ b/modules/Stylist/Providers/RouteServiceProvider.php @@ -0,0 +1,22 @@ +group(module_path('Stylist', 'Routes/assets.php')); + + Route::middleware('web') + ->prefix('stylist') + ->name('stylist.') + ->group(module_path('Stylist', 'Routes/web.php')); + } +} diff --git a/modules/Stylist/Providers/StylistServiceProvider.php b/modules/Stylist/Providers/StylistServiceProvider.php new file mode 100644 index 0000000..31ee054 --- /dev/null +++ b/modules/Stylist/Providers/StylistServiceProvider.php @@ -0,0 +1,215 @@ +app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); + + $this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class)); + $this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class)); + $this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class)); + } + + public function boot(): void + { + $this->registerViews(); + $this->registerThemeViews(); + $this->registerTranslations(); + $this->registerPublishing(); + $this->registerHelpers(); + $this->registerBlade(); + + if ($this->app->runningInConsole()) { + $this->commands([ + PublishAssetsCommand::class, + ValidateThemeCommand::class, + InspectThemesCommand::class, + FindDuplicateViewsCommand::class, + ThemeDoctorCommand::class, + CreateThemeCommand::class, + ]); + } + + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + + View::composer('*', function ($view): void { + if (! $this->app->bound(Stylist::class)) { + return; + } + + $view->with('activeTheme', $this->app->make(Stylist::class)->current()); + }); + + + View::composer('stylist-theme-saasland::*', function ($view): void { + $theme = $this->app->make(Loader::class)->find('saasland'); + $settings = $this->app->make(ThemeSettings::class)->get($theme); + $siteSettings = $this->app->make(SettingsRepository::class)->site(); + $navigation = $this->app->make(FrontendNavigation::class); + $homepage = $this->app->make(FrontendContent::class)->homepage($settings); + + $view->with([ + 'siteSettings' => $siteSettings, + 'siteTitle' => $siteSettings['site_name'], + 'siteDescription' => $siteSettings['site_description'], + 'logoUrl' => $siteSettings['logo_url'] ?: null, + 'facebookUrl' => $siteSettings['facebook_url'] ?: null, + 'xUrl' => $siteSettings['x_url'] ?: null, + 'linkedinUrl' => $siteSettings['linkedin_url'] ?: null, + 'primaryNavigation' => $navigation->primary(), + 'footerNavigation' => $navigation->footer(), + 'themeSettings' => $settings, + ...$homepage, + ]); + }); + } + + private function registerViews(): void + { + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $viewPath = resource_path('views/modules/'.ModuleInfo::nameLower()); + + $this->publishes([$sourcePath => $viewPath], ['views', 'stylist-module-views']); + $this->loadViewsFrom([$viewPath, $sourcePath], ModuleInfo::nameLower()); + } + + private function registerThemeViews(): void + { + foreach ($this->themeRoots() as $root) { + if (! is_dir($root)) { + logger()->warning('Stylist theme root does not exist', [ + 'root' => $root, + ]); + + continue; + } + + $themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: []; + + foreach ($themeDirectories as $themeDirectory) { + $themeViewsPath = $themeDirectory.'/views'; + + if (! is_dir($themeViewsPath)) { + continue; + } + + $themeKey = strtolower(basename($themeDirectory)); + + // Themes always live outside the module, normally in resources/themes. + // Register the canonical namespace and the legacy namespace as an alias + // to the same application-owned directory. The alias keeps existing + // routes and Blade templates working without reintroducing module themes. + $namespaces = [ + "stylist-theme-{$themeKey}", + "stylist-module-theme-{$themeKey}", + ]; + + $componentsPath = $themeViewsPath.'/components'; + + // The common theme is the application-wide owner of layouts, + // authentication, settings and shared components. Livewire 4 + // resolves its default full-page layout through layouts::app, + // so point that compatibility namespace at the common theme. + if ($themeKey === 'common') { + $layoutsPath = $componentsPath.'/layouts'; + + if (is_dir($layoutsPath)) { + View::addNamespace('layouts', $layoutsPath); + Blade::anonymousComponentPath($layoutsPath, 'layouts'); + } + + View::addNamespace('common', $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, 'common'); + } + } + + foreach ($namespaces as $namespace) { + View::addNamespace($namespace, $themeViewsPath); + + if (is_dir($componentsPath)) { + Blade::anonymousComponentPath($componentsPath, $namespace); + } + } + } + } + } + + private function normalisePath(string $path): string + { + return rtrim( + str_replace('\\', '/', realpath($path) ?: $path), + '/' + ); + } + + /** @return array */ + 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 () => 'variables(); ?>'); + Blade::directive('themeStylesheet', fn ($expression) => 'stylesheet('.($expression ?: 'null').'); ?>'); + Blade::directive('themeScript', fn ($expression) => 'script('.($expression ?: 'null').'); ?>'); + Blade::directive('themeSwitcher', fn () => 'switcher(); ?>'); + Blade::directive('themeSeo', fn ($expression) => 'render('.($expression ?: '[]').'); ?>'); + } +} diff --git a/modules/Stylist/README.md b/modules/Stylist/README.md new file mode 100644 index 0000000..810bf53 --- /dev/null +++ b/modules/Stylist/README.md @@ -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 + + @themeVariables + @themeStylesheet + +``` + +## 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 `` 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. diff --git a/modules/Stylist/Resources/assets/css/app.css b/modules/Stylist/Resources/assets/css/app.css new file mode 100644 index 0000000..32b4fbc --- /dev/null +++ b/modules/Stylist/Resources/assets/css/app.css @@ -0,0 +1,5 @@ +[data-theme] { + --brand-primary: var(--brand-primary, #2563eb); + --brand-secondary: var(--brand-secondary, #0f172a); + --brand-accent: var(--brand-accent, #22c55e); +} diff --git a/modules/Stylist/Resources/assets/js/app.js b/modules/Stylist/Resources/assets/js/app.js new file mode 100644 index 0000000..f65cc9b --- /dev/null +++ b/modules/Stylist/Resources/assets/js/app.js @@ -0,0 +1 @@ +import '../css/app.css'; diff --git a/modules/Stylist/Resources/lang/en/messages.php b/modules/Stylist/Resources/lang/en/messages.php new file mode 100644 index 0000000..56494b1 --- /dev/null +++ b/modules/Stylist/Resources/lang/en/messages.php @@ -0,0 +1,5 @@ + 'Stylist', +]; diff --git a/modules/Stylist/Resources/views/index.blade.php b/modules/Stylist/Resources/views/index.blade.php new file mode 100644 index 0000000..19ffd23 --- /dev/null +++ b/modules/Stylist/Resources/views/index.blade.php @@ -0,0 +1,78 @@ + + + + @if (session('status')) +
{{ session('status') }}
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + +
+ +
+ @foreach ($themes as $item) + @php($saved = $settingsService->get($item)) + @php($variant = $settingsService->variant($item)) + @php($issues = $accessibility->validate($item)) + @php($appearance = $settingsService->appearance($item)) + @php($accent = $settingsService->accent($item)) +
+
+ @if ($item->preview() && $item->has($item->preview())) + {{ $item->name() }} theme preview + @else +
Add preview.webp to this theme
+ @endif +
+
+

{{ $item->name() }}

v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif

@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))Active {{ ucfirst($item->type()) }}@endif
+ @if($item->description())

{{ $item->description() }}

@endif + @if($item->parent())

Child of {{ $item->parent() }}

@endif +
+ @if(in_array($item->type(), ['frontend', 'backend'], true)) +
@csrf
+ Preview + @endif + Export ZIP +
@csrf
+ @if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__')) +
@csrf @method('DELETE')
+ @endif +
+
+ Theme settings +
@csrf @method('PUT') + @if($item->variants()) + + @endif + @if($item->supports('dark_mode') || $item->appearances()) + + @endif + @if($item->accents()) +
Accent colour
@foreach($item->accents() as $name => $definition)@endforeach
+ @endif + @foreach($item->settingsSchema() as $key => $definition) + @php($definition = is_array($definition) ? $definition : ['default' => $definition]) + + @endforeach + +
+
+
Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}
+
+
+ @endforeach +
+
diff --git a/modules/Stylist/Routes/assets.php b/modules/Stylist/Routes/assets.php new file mode 100644 index 0000000..9662db6 --- /dev/null +++ b/modules/Stylist/Routes/assets.php @@ -0,0 +1,16 @@ +where('theme', '[A-Za-z0-9_-]+') + ->where('path', '.*') + ->name('stylist.theme.asset'); diff --git a/modules/Stylist/Routes/web.php b/modules/Stylist/Routes/web.php new file mode 100644 index 0000000..c39ec85 --- /dev/null +++ b/modules/Stylist/Routes/web.php @@ -0,0 +1,17 @@ +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'); +}); diff --git a/modules/Stylist/Services/FrontendContent.php b/modules/Stylist/Services/FrontendContent.php new file mode 100644 index 0000000..e57aac5 --- /dev/null +++ b/modules/Stylist/Services/FrontendContent.php @@ -0,0 +1,102 @@ + */ + 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); + } +} diff --git a/modules/Stylist/Services/TenantThemeResolver.php b/modules/Stylist/Services/TenantThemeResolver.php new file mode 100644 index 0000000..58d4920 --- /dev/null +++ b/modules/Stylist/Services/TenantThemeResolver.php @@ -0,0 +1,65 @@ +context(); + $column = config('stylist.tenant_theme_column', 'theme_key'); + $user = Auth::user(); + $company = $user?->company ?? null; + + if ($company && filled($company->{$column} ?? null)) { + return (string) $company->{$column}; + } + + if (function_exists('tenant')) { + $tenant = tenant(); + if ($tenant && filled($tenant->{$column} ?? null)) { + return (string) $tenant->{$column}; + } + } + + $settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme'; + $default = $context === 'frontend' + ? config('stylist.default_frontend_theme', 'saasland') + : config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); + + return (string) $this->settings->get($settingKey, $default, 'site'); + } + + public function context(): string + { + if (app()->runningInConsole()) { + return 'backend'; + } + + $request = request(); + + return $request->routeIs('home', 'blog.public.*', 'news.public.*', 'pages.public.*') + || ! $request->user() + ? 'frontend' + : 'backend'; + } + + public function variables(): array + { + $variables = config('stylist.fallback_variables', []); + $company = Auth::user()?->company ?? null; + + foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) { + if ($company && filled($company->{$column} ?? null)) { + $variables[$variable] = (string) $company->{$column}; + } + } + + return $variables; + } +} diff --git a/modules/Stylist/Services/ThemeAccessibilityValidator.php b/modules/Stylist/Services/ThemeAccessibilityValidator.php new file mode 100644 index 0000000..70b68bb --- /dev/null +++ b/modules/Stylist/Services/ThemeAccessibilityValidator.php @@ -0,0 +1,32 @@ + */ + public function validate(Theme $theme): array + { + $issues = []; + foreach ($this->files->allFiles($theme->path().'/views') as $file) { + $content = $file->getContents(); + $relative = $file->getRelativePathname(); + if (preg_match_all('/]*\balt=)[^>]*>/i', $content, $matches)) { + $issues[] = ['severity' => 'error', 'message' => count($matches[0]).' image(s) missing alt text.', 'file' => $relative]; + } + if (preg_match('/]*\blang=)/i', $content)) { + $issues[] = ['severity' => 'warning', 'message' => 'HTML element is missing a lang attribute.', 'file' => $relative]; + } + if (preg_match_all('/]*>\s*(?:<[^>]+>\s*)*<\/button>/is', $content, $matches)) { + $issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative]; + } + } + + return $issues; + } +} diff --git a/modules/Stylist/Services/ThemeOptimizer.php b/modules/Stylist/Services/ThemeOptimizer.php new file mode 100644 index 0000000..def6927 --- /dev/null +++ b/modules/Stylist/Services/ThemeOptimizer.php @@ -0,0 +1,57 @@ + 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; + } +} diff --git a/modules/Stylist/Services/ThemePackageManager.php b/modules/Stylist/Services/ThemePackageManager.php new file mode 100644 index 0000000..56d1a4a --- /dev/null +++ b/modules/Stylist/Services/ThemePackageManager.php @@ -0,0 +1,98 @@ +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; + } +} diff --git a/modules/Stylist/Services/ThemeSettings.php b/modules/Stylist/Services/ThemeSettings.php new file mode 100644 index 0000000..0a1f253 --- /dev/null +++ b/modules/Stylist/Services/ThemeSettings.php @@ -0,0 +1,97 @@ +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; + } + } +} diff --git a/modules/Stylist/Services/ThemeValidator.php b/modules/Stylist/Services/ThemeValidator.php new file mode 100644 index 0000000..472397c --- /dev/null +++ b/modules/Stylist/Services/ThemeValidator.php @@ -0,0 +1,74 @@ + */ + public function validatePath(string $path): array + { + $theme = $this->loader->fromPath($path); + + return $this->validate($theme); + } + + /** @return array */ + 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)); + } +} diff --git a/modules/Stylist/Support/FrontendNavigation.php b/modules/Stylist/Support/FrontendNavigation.php new file mode 100644 index 0000000..711f089 --- /dev/null +++ b/modules/Stylist/Support/FrontendNavigation.php @@ -0,0 +1,76 @@ +> */ + public function primary(): array + { + return $this->location('primary', $this->fallbackPrimary()); + } + + /** @return array> */ + public function footer(): array + { + return $this->location('footer', $this->fallbackPrimary()); + } + + /** @param array> $fallback + * @return array> + */ + 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 $items + * @return array> + */ + 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> */ + 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; + } +} diff --git a/modules/Stylist/Support/ModuleInfo.php b/modules/Stylist/Support/ModuleInfo.php new file mode 100644 index 0000000..6c2f429 --- /dev/null +++ b/modules/Stylist/Support/ModuleInfo.php @@ -0,0 +1,16 @@ +assertTrue(view()->exists($view), "Expected common view [{$view}] to exist."); + } + } + + public function test_authentication_and_settings_views_are_not_duplicated(): void + { + $this->assertDirectoryDoesNotExist(resource_path('views')); + $this->assertDirectoryDoesNotExist(resource_path('themes/admin/views/livewire')); + $this->assertDirectoryDoesNotExist(resource_path('themes/welcome/views/livewire')); + + $commonViews = resource_path('themes/common/views'); + + foreach ([ + 'livewire/auth/login.blade.php', + 'livewire/auth/register.blade.php', + 'livewire/auth/forgot-password.blade.php', + 'livewire/auth/reset-password.blade.php', + 'livewire/settings/profile.blade.php', + 'livewire/settings/appearance.blade.php', + 'livewire/settings/security.blade.php', + ] as $relativePath) { + $matches = collect(File::allFiles(resource_path('themes'))) + ->filter(fn ($file) => str_ends_with( + str_replace('\\', '/', $file->getPathname()), + '/views/'.$relativePath + )); + + $this->assertCount(1, $matches, "Expected one authoritative view for [{$relativePath}]."); + $this->assertStringStartsWith( + str_replace('\\', '/', $commonViews), + str_replace('\\', '/', $matches->first()->getPathname()) + ); + } + } +} diff --git a/modules/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php b/modules/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php new file mode 100644 index 0000000..2d0fd45 --- /dev/null +++ b/modules/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php @@ -0,0 +1,14 @@ +assertTrue(view()->exists('stylist-module-theme-admin::dashboard')); + $this->assertTrue(view()->exists('stylist-theme-admin::dashboard')); + } +} diff --git a/modules/Stylist/Tests/Feature/StylistModuleTest.php b/modules/Stylist/Tests/Feature/StylistModuleTest.php new file mode 100644 index 0000000..386ac03 --- /dev/null +++ b/modules/Stylist/Tests/Feature/StylistModuleTest.php @@ -0,0 +1,21 @@ +assertInstanceOf(Stylist::class, app(Stylist::class)); + } + + public function test_default_theme_resolves(): void + { + config(['stylist.default_theme' => 'admin']); + + $this->assertSame('admin', app(Stylist::class)->current()->key()); + } +} diff --git a/modules/Stylist/Tests/Feature/ThemeAssetControllerTest.php b/modules/Stylist/Tests/Feature/ThemeAssetControllerTest.php new file mode 100644 index 0000000..876ee02 --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemeAssetControllerTest.php @@ -0,0 +1,41 @@ + resource_path('themes')]); + app(Loader::class)->flush(); + + $this->get(route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'css/theme.css', + ]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff'); + } + + public function test_asset_url_uses_public_themes_path(): void + { + $url = route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'images/example.jpg', + ], false); + + $this->assertSame('/themes/saasland/images/example.jpg', $url); + } + + public function test_non_asset_theme_files_are_not_served(): void + { + config(['stylist.themes_path' => resource_path('themes')]); + app(Loader::class)->flush(); + + $this->get(route('stylist.theme.asset', [ + 'theme' => 'saasland', + 'path' => 'views/home.blade.php', + ]))->assertNotFound(); + } +} diff --git a/modules/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php b/modules/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php new file mode 100644 index 0000000..65b03aa --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php @@ -0,0 +1,18 @@ + '#111827']); + + $html = app(ThemeHtmlBuilder::class)->variables()->toHtml(); + + $this->assertStringContainsString('--brand-primary: #111827;', $html); + } +} diff --git a/modules/Stylist/Tests/Feature/ThemeInheritanceTest.php b/modules/Stylist/Tests/Feature/ThemeInheritanceTest.php new file mode 100644 index 0000000..495f581 --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemeInheritanceTest.php @@ -0,0 +1,95 @@ +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' => '
{{ $slot }}
']); + $this->theme('admin', 'common', ['dashboard.blade.php' => '

Dashboard

']); + 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 $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); + } + } +} diff --git a/modules/Stylist/Tests/Feature/ThemeManifestTest.php b/modules/Stylist/Tests/Feature/ThemeManifestTest.php new file mode 100644 index 0000000..11d681e --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemeManifestTest.php @@ -0,0 +1,157 @@ +themesPath = storage_path('framework/testing/stylist-themes-'.uniqid()); + mkdir($this->themesPath, 0777, true); + config(['stylist.themes_path' => $this->themesPath]); + app(Loader::class)->flush(); + } + + protected function tearDown(): void + { + app(Filesystem::class)->deleteDirectory($this->themesPath); + parent::tearDown(); + } + + public function test_manifest_is_normalised(): void + { + $path = $this->themesPath.'/sample'; + mkdir($path.'/css', 0777, true); + file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample'])); + file_put_contents($path.'/css/theme.css', 'body{}'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame('sample', $theme->key()); + $this->assertSame('css/theme.css', $theme->stylesheet()); + $this->assertSame('js/app.js', $theme->script()); + $this->assertSame('home', $theme->entryView()); + } + + public function test_unsafe_manifest_path_is_rejected(): void + { + $path = $this->themesPath.'/unsafe'; + mkdir($path, 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Unsafe', + 'stylesheet' => '../secret.css', + ])); + + $this->expectException(InvalidThemeManifestException::class); + app(Loader::class)->fromPath($path); + } + + public function test_phase_one_manifest_optional_capabilities_are_normalised(): void + { + $path = $this->themesPath.'/design-gallery'; + mkdir($path.'/css', 0777, true); + mkdir($path.'/js', 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'key' => 'design-gallery', + 'name' => 'Design Gallery', + 'stylesheet' => 'css/app.css', + 'script' => 'js/app.js', + 'appearances' => ['light', 'dark', 'system'], + 'accents' => ['blue', 'emerald', 'neutral'], + 'default_appearance' => 'system', + 'default_accent' => 'neutral', + // Phase 1 incorrectly duplicated accents here; Stylist must not + // reject the whole theme because of that compact legacy list. + 'variants' => ['blue', 'emerald', 'neutral'], + ])); + file_put_contents($path.'/css/app.css', 'body{}'); + file_put_contents($path.'/js/app.js', ''); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances())); + $this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents())); + $this->assertSame([], $theme->variants()); + $this->assertSame('system', $theme->defaultAppearance()); + $this->assertSame('neutral', $theme->defaultAccent()); + } + + public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void + { + $path = $this->themesPath.'/legacy'; + mkdir($path.'/css', 0777, true); + file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy'])); + file_put_contents($path.'/css/theme.css', 'body{}'); + + $theme = app(Loader::class)->fromPath($path); + + $this->assertSame([], $theme->appearances()); + $this->assertSame([], $theme->accents()); + $this->assertSame('', $theme->defaultAppearance()); + $this->assertSame('', $theme->defaultAccent()); + } + + public function test_defaults_must_reference_declared_optional_capabilities(): void + { + $path = $this->themesPath.'/invalid-default'; + mkdir($path, 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'name' => 'Invalid Default', + 'appearances' => ['light', 'dark'], + 'default_appearance' => 'system', + ])); + + $this->expectException(InvalidThemeManifestException::class); + app(Loader::class)->fromPath($path); + } + + public function test_manifest_version_and_capabilities_are_normalised(): void + { + $path = $this->themesPath.'/capable'; + mkdir($path.'/css', 0777, true); + mkdir($path.'/js', 0777, true); + mkdir($path.'/views', 0777, true); + file_put_contents($path.'/theme.json', json_encode([ + 'manifest_version' => 1, + 'name' => 'Capable', + 'appearances' => ['light', 'dark'], + 'accents' => ['blue'], + 'capabilities' => ['marketplace' => true], + ])); + file_put_contents($path.'/css/theme.css', 'body{}'); + file_put_contents($path.'/js/app.js', ''); + file_put_contents($path.'/views/home.blade.php', '
Capable
'); + + $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']); + } +} diff --git a/modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php b/modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php new file mode 100644 index 0000000..633b3dc --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php @@ -0,0 +1,79 @@ +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', '
Design Gallery works
'); + + $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(), + ); + } +} diff --git a/modules/Stylist/Tests/Feature/ThemeViewResolverTest.php b/modules/Stylist/Tests/Feature/ThemeViewResolverTest.php new file mode 100644 index 0000000..0ae83e8 --- /dev/null +++ b/modules/Stylist/Tests/Feature/ThemeViewResolverTest.php @@ -0,0 +1,22 @@ + 'admin', + 'stylist.themes_path' => resource_path('themes'), + ]); + + $candidates = app(ThemeViewResolver::class)->candidates('components.page-hero'); + + $this->assertContains('stylist-theme-admin::components.page-hero', $candidates); + $this->assertContains('stylist::components.page-hero', $candidates); + } +} diff --git a/modules/Stylist/Tests/Feature/WelcomeThemeViewTest.php b/modules/Stylist/Tests/Feature/WelcomeThemeViewTest.php new file mode 100644 index 0000000..ec7624b --- /dev/null +++ b/modules/Stylist/Tests/Feature/WelcomeThemeViewTest.php @@ -0,0 +1,21 @@ +assertTrue(view()->exists('stylist-theme-saasland::home')); + } + + public function test_home_route_renders_the_saasland_theme(): void + { + $this->get('/') + ->assertOk() + ->assertViewIs('stylist-theme-saasland::home') + ->assertSee('Transform Your Business With Our SaaS Solution'); + } +} diff --git a/modules/Stylist/Theme/Exceptions/InvalidThemeManifestException.php b/modules/Stylist/Theme/Exceptions/InvalidThemeManifestException.php new file mode 100644 index 0000000..a548f9f --- /dev/null +++ b/modules/Stylist/Theme/Exceptions/InvalidThemeManifestException.php @@ -0,0 +1,18 @@ + $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), + )); + } +} diff --git a/modules/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php b/modules/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php new file mode 100644 index 0000000..746d52e --- /dev/null +++ b/modules/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php @@ -0,0 +1,13 @@ +files->exists($manifest)) { + throw ThemeJsonNotFoundException::forPath($path); + } + + return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/modules/Stylist/Theme/Loader.php b/modules/Stylist/Theme/Loader.php new file mode 100644 index 0000000..e93708d --- /dev/null +++ b/modules/Stylist/Theme/Loader.php @@ -0,0 +1,83 @@ +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 */ + public function roots(): array + { + return array_values(array_unique(array_filter([ + (string) config('stylist.themes_path'), + ]))); + } +} diff --git a/modules/Stylist/Theme/ManifestValidator.php b/modules/Stylist/Theme/ManifestValidator.php new file mode 100644 index 0000000..cc75998 --- /dev/null +++ b/modules/Stylist/Theme/ManifestValidator.php @@ -0,0 +1,179 @@ + */ + 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 $value + * @param array $errors + * @return array> + */ + 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 $value + * @param array $errors + * @return array + */ + 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"); + } +} diff --git a/modules/Stylist/Theme/Stylist.php b/modules/Stylist/Theme/Stylist.php new file mode 100644 index 0000000..82793ef --- /dev/null +++ b/modules/Stylist/Theme/Stylist.php @@ -0,0 +1,103 @@ +resolver->resolve($context); + try { + return $this->loader->find($requested); + } catch (ThemeNotFoundException) { + $fallback = $context === 'frontend' + ? (string) config('stylist.default_frontend_theme', 'saasland') + : (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); + if ($requested !== $fallback) { + return $this->loader->find($fallback); + } + throw ThemeNotFoundException::forKey($requested); + } + } + + public function frontend(): Theme + { + return $this->current('frontend'); + } + + public function backend(): Theme + { + return $this->current('backend'); + } + + public function variables(): array + { + $theme = $this->current(); + $variant = $this->settings->variant($theme); + $variantVars = (array) data_get($theme->variants(), $variant.'.variables', []); + $appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []); + $accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []); + $settingVars = collect($this->settings->get($theme)) + ->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--')) + ->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value]) + ->all(); + + return array_replace( + (array) config('stylist.fallback_variables', []), + $this->inheritedVariables($theme), + $variantVars, + $appearanceVars, + $accentVars, + $settingVars, + $this->resolver->variables(), + ); + } + + public function settings(): array + { + return $this->settings->get($this->current()); + } + + public function variant(): string + { + return $this->settings->variant($this->current()); + } + + public function appearance(): string + { + return $this->settings->appearance($this->current()); + } + + public function accent(): string + { + return $this->settings->accent($this->current()); + } + + public function all() + { + return $this->loader->all(); + } + + private function inheritedVariables(Theme $theme, array $seen = []): array + { + if (in_array($theme->key(), $seen, true)) { + return []; + } + $seen[] = $theme->key(); + $parent = []; + if ($theme->parent()) { + try { + $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); + } catch (ThemeNotFoundException) { + } + } + + return array_replace($parent, $theme->variables()); + } +} diff --git a/modules/Stylist/Theme/Theme.php b/modules/Stylist/Theme/Theme.php new file mode 100644 index 0000000..2010173 --- /dev/null +++ b/modules/Stylist/Theme/Theme.php @@ -0,0 +1,161 @@ +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; + } +} diff --git a/modules/Stylist/Theme/UrlGenerator.php b/modules/Stylist/Theme/UrlGenerator.php new file mode 100644 index 0000000..77d08f8 --- /dev/null +++ b/modules/Stylist/Theme/UrlGenerator.php @@ -0,0 +1,20 @@ +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, '/')]); + } +} diff --git a/modules/Stylist/View/ThemeViewResolver.php b/modules/Stylist/View/ThemeViewResolver.php new file mode 100644 index 0000000..91d263d --- /dev/null +++ b/modules/Stylist/View/ThemeViewResolver.php @@ -0,0 +1,130 @@ +> */ + 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 */ + public function candidates(string $view): array + { + return $this->candidatesForTheme($this->stylist->current()->key(), $view); + } + + /** @return array */ + 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 */ + 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); + } +} diff --git a/modules/Stylist/changelog.yaml b/modules/Stylist/changelog.yaml new file mode 100644 index 0000000..7a8e4e0 --- /dev/null +++ b/modules/Stylist/changelog.yaml @@ -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. diff --git a/modules/Stylist/composer.json b/modules/Stylist/composer.json new file mode 100644 index 0000000..d537e3a --- /dev/null +++ b/modules/Stylist/composer.json @@ -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/" + } + } +} diff --git a/modules/Stylist/module.json b/modules/Stylist/module.json new file mode 100644 index 0000000..214c2d2 --- /dev/null +++ b/modules/Stylist/module.json @@ -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": [] +} diff --git a/modules/Stylist/package.json b/modules/Stylist/package.json new file mode 100644 index 0000000..e0b33af --- /dev/null +++ b/modules/Stylist/package.json @@ -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" + } +} diff --git a/modules/Stylist/vite.config.js b/modules/Stylist/vite.config.js new file mode 100644 index 0000000..de55470 --- /dev/null +++ b/modules/Stylist/vite.config.js @@ -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(), + ], +});