From 4949520194a8397497e15155e90c46de46693bde Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 1 Feb 2026 11:53:46 +0000 Subject: [PATCH] Theme System: Added initial module implementations --- app/App/Providers/ThemeServiceProvider.php | 3 +- app/Theming/ThemeController.php | 7 +- app/Theming/ThemeModule.php | 50 ++++++++++++ app/Theming/ThemeService.php | 93 ++++++++++++++++++++-- app/Theming/ThemeViews.php | 10 ++- app/Translation/FileLoader.php | 19 +++-- app/Util/SvgIcon.php | 11 ++- 7 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 app/Theming/ThemeModule.php diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index e32f90b9a..50c76bbf8 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -31,12 +31,13 @@ class ThemeServiceProvider extends ServiceProvider return; } + $themeService->loadModules(); $themeService->readThemeActions(); $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); $themeViews = new ThemeViews(); $themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews); - $themeViews->registerViewPathsForTheme($viewFactory->getFinder()); + $themeViews->registerViewPathsForTheme($viewFactory->getFinder(), $themeService->getModules()); if ($themeViews->hasRegisteredViews()) { $viewFactory->share('__themeViews', $themeViews); Blade::directive('include', function ($expression) { diff --git a/app/Theming/ThemeController.php b/app/Theming/ThemeController.php index 1eecc6974..c26767803 100644 --- a/app/Theming/ThemeController.php +++ b/app/Theming/ThemeController.php @@ -5,21 +5,22 @@ namespace BookStack\Theming; use BookStack\Facades\Theme; use BookStack\Http\Controller; use BookStack\Util\FilePathNormalizer; +use Symfony\Component\HttpFoundation\StreamedResponse; class ThemeController extends Controller { /** * Serve a public file from the configured theme. */ - public function publicFile(string $theme, string $path) + public function publicFile(string $theme, string $path): StreamedResponse { $cleanPath = FilePathNormalizer::normalize($path); if ($theme !== Theme::getTheme() || !$cleanPath) { abort(404); } - $filePath = theme_path("public/{$cleanPath}"); - if (!file_exists($filePath)) { + $filePath = Theme::findFirstFile("public/{$cleanPath}"); + if (!$filePath) { abort(404); } diff --git a/app/Theming/ThemeModule.php b/app/Theming/ThemeModule.php new file mode 100644 index 000000000..9bbc0103a --- /dev/null +++ b/app/Theming/ThemeModule.php @@ -0,0 +1,50 @@ +name = $data['name']; + $module->description = $data['description']; + $module->folderName = $folderName; + $module->version = $data['version']; + + return $module; + } + + /** + * Get a path for a file within this module. + */ + public function path($path = ''): string + { + $component = trim($path, '/'); + return theme_path("modules/{$this->folderName}/{$component}"); + } +} diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 14281adca..6f3112980 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -16,6 +16,11 @@ class ThemeService */ protected array $listeners = []; + /** + * @var array + */ + protected array $modules = []; + /** * Get the currently configured theme. * Returns an empty string if not configured. @@ -77,22 +82,96 @@ class ThemeService } /** - * Read any actions from the set theme path if the 'functions.php' file exists. + * Read any actions from the 'functions.php' file of the active theme or its modules. */ public function readThemeActions(): void { - $themeActionsFile = theme_path('functions.php'); - if (!$themeActionsFile || !file_exists($themeActionsFile)) { + $moduleFunctionFiles = array_map(function (ThemeModule $module): string { + return $module->path('functions.php'); + }, $this->modules); + $allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]); + $filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool { + return $file && file_exists($file); + }); + + foreach ($filteredFunctionFiles as $functionFile) { + try { + require $functionFile; + } catch (\Error $exception) { + throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}"); + } + } + } + + /** + * Read the modules folder and load in any valid theme modules. + */ + public function loadModules(): void + { + $modulesFolder = theme_path('modules'); + if (!$modulesFolder || !is_dir($modulesFolder)) { return; } - try { - require $themeActionsFile; - } catch (\Error $exception) { - throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}"); + $subFolders = array_filter(scandir($modulesFolder), function ($item) use ($modulesFolder) { + return $item !== '.' && $item !== '..' && is_dir($modulesFolder . DIRECTORY_SEPARATOR . $item); + }); + + foreach ($subFolders as $folderName) { + $moduleJsonFile = $modulesFolder . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json'; + + if (!file_exists($moduleJsonFile)) { + continue; + } + + try { + $jsonContent = file_get_contents($moduleJsonFile); + $jsonData = json_decode($jsonContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ThemeException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg()); + } + + $module = ThemeModule::fromJson($jsonData, $folderName); + $this->modules[$folderName] = $module; + } catch (ThemeException $exception) { + throw $exception; + } catch (\Exception $exception) { + throw new ThemeException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}"); + } } } + /** + * Get all loaded theme modules. + * @return array + */ + public function getModules(): array + { + return $this->modules; + } + + /** + * Look for a specific file within the theme or its modules. + * Returns the first file found or null if not found. + */ + public function findFirstFile(string $path): ?string + { + $themePath = theme_path($path); + if (file_exists($themePath)) { + return $themePath; + } + + foreach ($this->modules as $module) { + $customizedFile = $module->path($path); + if (file_exists($customizedFile)) { + return $customizedFile; + } + } + + return null; + } + /** * @see SocialDriverManager::addSocialDriver */ diff --git a/app/Theming/ThemeViews.php b/app/Theming/ThemeViews.php index 719f8e3ce..b2d0adc02 100644 --- a/app/Theming/ThemeViews.php +++ b/app/Theming/ThemeViews.php @@ -20,9 +20,17 @@ class ThemeViews /** * Register any extra paths for where we may expect views to be located * with the provided FileViewFinder, to make custom views available for use. + * @param ThemeModule[] $modules */ - public function registerViewPathsForTheme(FileViewFinder $finder): void + public function registerViewPathsForTheme(FileViewFinder $finder, array $modules): void { + foreach ($modules as $module) { + $moduleViewsPath = $module->path('views'); + if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) { + $finder->prependLocation($moduleViewsPath); + } + } + $finder->prependLocation(theme_path()); } diff --git a/app/Translation/FileLoader.php b/app/Translation/FileLoader.php index 1fec4d18b..6212506dd 100644 --- a/app/Translation/FileLoader.php +++ b/app/Translation/FileLoader.php @@ -2,6 +2,7 @@ namespace BookStack\Translation; +use BookStack\Facades\Theme; use Illuminate\Translation\FileLoader as BaseLoader; class FileLoader extends BaseLoader @@ -12,11 +13,6 @@ class FileLoader extends BaseLoader * Extends Laravel's translation FileLoader to look in multiple directories * so that we can load in translation overrides from the theme file if wanted. * - * Note: As of using Laravel 10, this may now be redundant since Laravel's - * file loader supports multiple paths. This needs further testing though - * to confirm if Laravel works how we expect, since we specifically need - * the theme folder to be able to partially override core lang files. - * * @param string $locale * @param string $group * @param string|null $namespace @@ -32,9 +28,18 @@ class FileLoader extends BaseLoader if (is_null($namespace) || $namespace === '*') { $themePath = theme_path('lang'); $themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : []; - $originalTranslations = $this->loadPaths($this->paths, $locale, $group); - return array_merge($originalTranslations, $themeTranslations); + $modules = Theme::getModules(); + $moduleTranslations = []; + foreach ($modules as $module) { + $modulePath = $module->path('lang'); + if (file_exists($modulePath)) { + $moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group)); + } + } + + $originalTranslations = $this->loadPaths($this->paths, $locale, $group); + return array_merge($originalTranslations, $moduleTranslations, $themeTranslations); } return $this->loadNamespaced($locale, $group, $namespace); diff --git a/app/Util/SvgIcon.php b/app/Util/SvgIcon.php index ce6e1c23e..b1b14a487 100644 --- a/app/Util/SvgIcon.php +++ b/app/Util/SvgIcon.php @@ -2,6 +2,8 @@ namespace BookStack\Util; +use BookStack\Facades\Theme; + class SvgIcon { public function __construct( @@ -23,12 +25,9 @@ class SvgIcon $attrString .= $attrName . '="' . $attr . '" '; } - $iconPath = resource_path('icons/' . $this->name . '.svg'); - $themeIconPath = theme_path('icons/' . $this->name . '.svg'); - - if ($themeIconPath && file_exists($themeIconPath)) { - $iconPath = $themeIconPath; - } elseif (!file_exists($iconPath)) { + $defaultIconPath = resource_path('icons/' . $this->name . '.svg'); + $iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath; + if (!file_exists($iconPath)) { return ''; }