Files
2026-09-02 17:08:57 +00:00

458 lines
16 KiB
PHP
Executable File

<?php
namespace Modules\Stylist\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Modules\Stylist\Console\CreateThemeCommand;
use Modules\Stylist\Console\FindDuplicateViewsCommand;
use Modules\Stylist\Console\InspectThemesCommand;
use Modules\Stylist\Console\PublishAssetsCommand;
use Modules\Stylist\Console\ThemeDoctorCommand;
use Modules\Stylist\Console\ValidateThemeCommand;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Html\ThemeSeoBuilder;
use Modules\Stylist\Support\ModuleInfo;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
protected string $moduleNameLower = 'stylist';
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
$this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class));
$this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class));
$this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class));
}
public function boot(): void
{
$this->registerViews();
$this->registerThemeViews();
$this->registerTranslations();
$this->registerPublishing();
$this->registerHelpers();
$this->registerBlade();
if ($this->app->runningInConsole()) {
$this->commands([
PublishAssetsCommand::class,
ValidateThemeCommand::class,
InspectThemesCommand::class,
FindDuplicateViewsCommand::class,
ThemeDoctorCommand::class,
CreateThemeCommand::class,
]);
}
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
View::composer('*', function ($view): void {
if (! $this->app->bound(Stylist::class)) {
return;
}
$view->with('activeTheme', $this->app->make(Stylist::class)->current());
});
// Register both selected presentation trees immediately, then register them again
// after every service provider has finished booting. Some package/module providers
// replace Laravel's base view locations during their own boot phase; the booted
// callback makes Stylist the final authority for ordinary view('...') resolution.
$this->registerSelectedThemeOverrides();
$this->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<int, string> */
private function themeChain(Theme $theme): array
{
$loader = $this->app->make(Loader::class);
$chain = [];
$seen = [];
while (! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$chain[] = $theme->key();
$parent = $theme->parent();
if (! $parent) {
break;
}
try {
$theme = $loader->find($parent);
} catch (\Throwable) {
break;
}
}
return $chain;
}
private function prependThemeViewTree(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$finder = View::getFinder();
if (method_exists($finder, 'prependLocation')) {
$finder->prependLocation($viewsPath);
} else {
View::addLocation($viewsPath);
}
$this->registerThemeModuleNamespaces($themeKey);
}
private function registerLayoutCompatibilityNamespace(Theme $theme): void
{
$candidates = [
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'layouts',
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'components'.DIRECTORY_SEPARATOR.'layouts',
];
foreach ($candidates as $layoutsPath) {
if (! is_dir($layoutsPath)) {
continue;
}
View::prependNamespace('layouts', $layoutsPath);
Blade::anonymousComponentPath($layoutsPath, 'layouts');
}
}
private function registerThemeModuleNamespaces(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$modulesPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules';
if (! is_dir($modulesPath)) {
return;
}
foreach (glob($modulesPath.'/*', GLOB_ONLYDIR) ?: [] as $moduleViewsPath) {
$namespace = strtolower(basename($moduleViewsPath));
View::prependNamespace($namespace, $moduleViewsPath);
$componentsPath = $moduleViewsPath.DIRECTORY_SEPARATOR.'components';
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, $namespace);
}
}
}
private function normalisePath(string $path): string
{
return rtrim(
str_replace('\\', '/', realpath($path) ?: $path),
'/'
);
}
/** @return array<int, string> */
private function themeRoots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
private function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
$this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
}
private function registerPublishing(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
], ['config', 'stylist-config']);
}
private function registerHelpers(): void
{
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
require_once $filename;
}
}
private function registerBlade(): void
{
Blade::directive('themeVariables', fn () => '<?php echo stylist_html()->variables(); ?>');
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
}
}