app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()); $this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class)); $this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class)); $this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class)); } public function boot(): void { $this->registerViews(); $this->registerThemeViews(); $this->registerTranslations(); $this->registerPublishing(); $this->registerHelpers(); $this->registerBlade(); if ($this->app->runningInConsole()) { $this->commands([ PublishAssetsCommand::class, ValidateThemeCommand::class, InspectThemesCommand::class, FindDuplicateViewsCommand::class, ThemeDoctorCommand::class, CreateThemeCommand::class, ]); } $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); View::composer('*', function ($view): void { if (! $this->app->bound(Stylist::class)) { return; } $view->with('activeTheme', $this->app->make(Stylist::class)->current()); }); // Register both selected presentation trees immediately, then register them again // after every service provider has finished booting. Some package/module providers // replace Laravel's base view locations during their own boot phase; the booted // callback makes Stylist the final authority for ordinary view('...') resolution. $this->registerSelectedThemeOverrides(); $this->registerSelectedThemeFluxOverrides(); $this->app->booted(function (): void { $this->registerSelectedThemeOverrides(); $this->registerSelectedThemeFluxOverrides(); }); } private function registerViews(): void { $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); // Module-owned package views stay inside the module. Application overrides // belong to resources/themes/{theme}/views/modules/stylist, which Stylist // registers as the authoritative themed namespace. Never publish back into // resources/views; the standard baseline intentionally has no legacy view tree. $this->loadViewsFrom($sourcePath, ModuleInfo::nameLower()); } private function registerThemeViews(): void { foreach ($this->themeRoots() as $root) { if (! is_dir($root)) { logger()->warning('Stylist theme root does not exist', [ 'root' => $root, ]); continue; } $themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: []; foreach ($themeDirectories as $themeDirectory) { $themeViewsPath = $themeDirectory.'/views'; if (! is_dir($themeViewsPath)) { continue; } $themeKey = strtolower(basename($themeDirectory)); // Themes always live outside the module, normally in resources/themes. // Register the canonical namespace and the legacy namespace as an alias // to the same application-owned directory. The alias keeps existing // routes and Blade templates working without reintroducing module themes. $namespaces = [ "stylist-theme-{$themeKey}", "stylist-module-theme-{$themeKey}", ]; $componentsPath = $themeViewsPath.'/components'; // The common theme is the application-wide owner of layouts, // authentication, settings and shared components. Livewire 4 // resolves its default full-page layout through layouts::app, // so point that compatibility namespace at the common theme. if ($themeKey === 'common') { $layoutsPath = $componentsPath.'/layouts'; if (is_dir($layoutsPath)) { View::addNamespace('layouts', $layoutsPath); Blade::anonymousComponentPath($layoutsPath, 'layouts'); } View::addNamespace('common', $themeViewsPath); if (is_dir($componentsPath)) { Blade::anonymousComponentPath($componentsPath, 'common'); } } foreach ($namespaces as $namespace) { View::addNamespace($namespace, $themeViewsPath); if (is_dir($componentsPath)) { Blade::anonymousComponentPath($componentsPath, $namespace); } } } } } private function registerSelectedThemeOverrides(): void { $stylist = $this->app->make(Stylist::class); $current = $stylist->current(); $frontend = $stylist->frontend(); $backend = $stylist->backend(); // The active request context must win ordinary view resolution. Keep the // opposite presentation tree available as a compatibility fallback, but // never let frontend views shadow backend views on backend requests (or // vice versa) simply because of provider registration order. $orderedThemes = [$current, $frontend, $backend]; $chains = []; $seenThemes = []; foreach ($orderedThemes as $theme) { if (isset($seenThemes[$theme->key()])) { continue; } $seenThemes[$theme->key()] = true; $chains[] = $this->themeChain($theme); } $keys = []; foreach ($chains as $chain) { foreach ($chain as $key) { if (! in_array($key, $keys, true)) { $keys[] = $key; } } } if (! in_array('common', $keys, true)) { $keys[] = 'common'; } // Laravel's starter kits resolve authenticated layouts through the // x-layouts:: namespace. Once those views live inside the backend // theme, Stylist must make that selected theme authoritative for the // namespace as well as for ordinary view('...') lookups. $this->registerLayoutCompatibilityNamespace($stylist->backend()); $finder = View::getFinder(); $themePaths = []; foreach ($keys as $key) { try { $theme = $this->app->make(Loader::class)->find($key); } catch (\Throwable) { continue; } $viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views'; if (is_dir($viewsPath)) { $themePaths[] = $viewsPath; } $this->registerThemeModuleNamespaces($key); } // FileViewFinder is the source of truth for ordinary view('...') resolution. // Set the complete ordered path list explicitly instead of relying on provider // boot order and incremental prependLocation() calls. Theme paths stay first; // Laravel's normal view locations remain as the final fallback. if (method_exists($finder, 'getPaths') && method_exists($finder, 'setPaths')) { // The application intentionally has no resources/views tree. Laravel's // default FileViewFinder still starts with that location, and view:cache // asks Symfony Finder to scan every registered path. Remove missing legacy // locations so theme-only applications can be optimized on a clean release. $existingPaths = array_values(array_filter( $finder->getPaths(), static fn (string $path): bool => is_dir($path), )); $paths = array_values(array_unique(array_merge($themePaths, $existingPaths))); $finder->setPaths($paths); if (method_exists($finder, 'flush')) { $finder->flush(); } } else { foreach (array_reverse($themePaths) as $viewsPath) { if (method_exists($finder, 'prependLocation')) { $finder->prependLocation($viewsPath); } else { View::addLocation($viewsPath); } } } } /** * Make Flux application overrides theme-owned. * * Flux registers anonymous component paths using the prefix "flux". Laravel * converts that prefix into a hashed view namespace (xxh128 on Laravel 12+). * Registering only a normal "flux" view namespace therefore does not satisfy * Flux::componentExists(). Register each themed views/flux directory through * Blade exactly as Flux itself does, then prepend it to the hashed namespace so * the selected theme wins over package stubs while retaining package fallbacks. */ private function registerSelectedThemeFluxOverrides(): void { $stylist = $this->app->make(Stylist::class); $orderedThemes = [$stylist->current(), $stylist->frontend(), $stylist->backend()]; $keys = []; foreach ($orderedThemes as $theme) { foreach ($this->themeChain($theme) as $key) { if (! in_array($key, $keys, true)) { $keys[] = $key; } } } if (! in_array('common', $keys, true)) { $keys[] = 'common'; } $themeFluxPaths = []; foreach ($keys as $key) { try { $theme = $this->app->make(Loader::class)->find($key); } catch (\Throwable) { continue; } $fluxPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'flux'; if (is_dir($fluxPath) && ! in_array($fluxPath, $themeFluxPaths, true)) { $themeFluxPaths[] = $fluxPath; } } if ($themeFluxPaths === []) { return; } // This mirrors FluxServiceProvider::bootComponentPath(). It is essential: // Flux::componentExists() checks the hashed namespace created by // Blade::anonymousComponentPath(..., 'flux'), not a literal flux:: namespace. foreach ($themeFluxPaths as $fluxPath) { Blade::anonymousComponentPath($fluxPath, 'flux'); } $fluxNamespace = version_compare($this->app->version(), '12.0.0', '>=') ? hash('xxh128', 'flux') : md5('flux'); // anonymousComponentPath() adds locations to the namespace. Prepend the // selected theme paths in reverse order so the first selected theme remains // authoritative while Flux package views continue to work as fallbacks. foreach (array_reverse($themeFluxPaths) as $fluxPath) { View::prependNamespace($fluxNamespace, $fluxPath); } $finder = View::getFinder(); if (method_exists($finder, 'flush')) { $finder->flush(); } } /** @return array */ private function themeChain(Theme $theme): array { $loader = $this->app->make(Loader::class); $chain = []; $seen = []; while (! isset($seen[$theme->key()])) { $seen[$theme->key()] = true; $chain[] = $theme->key(); $parent = $theme->parent(); if (! $parent) { break; } try { $theme = $loader->find($parent); } catch (\Throwable) { break; } } return $chain; } private function prependThemeViewTree(string $themeKey): void { try { $theme = $this->app->make(Loader::class)->find($themeKey); } catch (\Throwable) { return; } $viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views'; if (! is_dir($viewsPath)) { return; } $finder = View::getFinder(); if (method_exists($finder, 'prependLocation')) { $finder->prependLocation($viewsPath); } else { View::addLocation($viewsPath); } $this->registerThemeModuleNamespaces($themeKey); } private function registerLayoutCompatibilityNamespace(Theme $theme): void { $candidates = [ $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'layouts', $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'components'.DIRECTORY_SEPARATOR.'layouts', ]; foreach ($candidates as $layoutsPath) { if (! is_dir($layoutsPath)) { continue; } View::prependNamespace('layouts', $layoutsPath); Blade::anonymousComponentPath($layoutsPath, 'layouts'); } } private function registerThemeModuleNamespaces(string $themeKey): void { try { $theme = $this->app->make(Loader::class)->find($themeKey); } catch (\Throwable) { return; } $modulesPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules'; if (! is_dir($modulesPath)) { return; } foreach (glob($modulesPath.'/*', GLOB_ONLYDIR) ?: [] as $moduleViewsPath) { $namespace = strtolower(basename($moduleViewsPath)); View::prependNamespace($namespace, $moduleViewsPath); $componentsPath = $moduleViewsPath.DIRECTORY_SEPARATOR.'components'; if (is_dir($componentsPath)) { Blade::anonymousComponentPath($componentsPath, $namespace); } } } private function normalisePath(string $path): string { return rtrim( str_replace('\\', '/', realpath($path) ?: $path), '/' ); } /** @return array */ 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 ?: '[]').'); ?>'); } }