From 36649a618858e0309fce0f1d5ce90bfaf47c4605 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 Jan 2026 11:55:39 +0000 Subject: [PATCH 1/4] Theme: Updated view registration to be dynamic Within the responsibility of the theme service instead of being part of the app configuration. --- app/App/Providers/ThemeServiceProvider.php | 1 + app/Config/view.php | 8 +------- app/Theming/ThemeService.php | 10 ++++++++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 2cf581d3863..a806c1df622 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -24,6 +24,7 @@ public function boot(): void { // Boot up the theme system $themeService = $this->app->make(ThemeService::class); + $themeService->registerViewPathsForTheme($this->app->make('view')->getFinder()); $themeService->readThemeActions(); $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); } diff --git a/app/Config/view.php b/app/Config/view.php index 80bc9ef8fe8..2eb30b4c9de 100644 --- a/app/Config/view.php +++ b/app/Config/view.php @@ -8,12 +8,6 @@ * Do not edit this file unless you're happy to maintain any changes yourself. */ -// Join up possible view locations -$viewPaths = [realpath(base_path('resources/views'))]; -if ($theme = env('APP_THEME', false)) { - array_unshift($viewPaths, base_path('themes/' . $theme)); -} - return [ // App theme @@ -26,7 +20,7 @@ // Most templating systems load templates from disk. Here you may specify // an array of paths that should be checked for your views. Of course // the usual Laravel view path has already been registered for you. - 'paths' => $viewPaths, + 'paths' => [realpath(base_path('resources/views'))], // Compiled View Path // This option determines where all the compiled Blade templates will be diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 4bdb6836b02..87811f0efd1 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\ThemeException; use Illuminate\Console\Application; use Illuminate\Console\Application as Artisan; +use Illuminate\View\FileViewFinder; use Symfony\Component\Console\Command\Command; class ThemeService @@ -90,6 +91,15 @@ public function readThemeActions(): void } } + /** + * Register any extra paths for where we may expect views to be located + * with the provided FileViewFinder, to make custom views available for use. + */ + public function registerViewPathsForTheme(FileViewFinder $finder): void + { + $finder->prependLocation(theme_path()); + } + /** * @see SocialDriverManager::addSocialDriver */ From c32b1686a95e17ab143faac1eb6b1611fe0e5286 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 Jan 2026 17:16:14 +0000 Subject: [PATCH 2/4] Theme: Added the ability to add views before/after existing ones Adds a registration system via the logical theme system, to tell BookStack about views to render before or after a specific template is included in the system. --- app/App/Providers/ThemeServiceProvider.php | 17 ++++- app/Theming/ThemeService.php | 88 ++++++++++++++++++++-- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index a806c1df622..98ad509f355 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -4,7 +4,9 @@ use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeService; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Illuminate\View\View; class ThemeServiceProvider extends ServiceProvider { @@ -24,8 +26,17 @@ public function boot(): void { // Boot up the theme system $themeService = $this->app->make(ThemeService::class); - $themeService->registerViewPathsForTheme($this->app->make('view')->getFinder()); - $themeService->readThemeActions(); - $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); + + $viewFactory = $this->app->make('view'); + $themeService->registerViewPathsForTheme($viewFactory->getFinder()); + + if ($themeService->logicalThemeIsActive()) { + $themeService->readThemeActions(); + $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); + $viewFactory->share('__theme', $themeService); + Blade::directive('include', function ($expression) { + return "handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>"; + }); + } } } diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 87811f0efd1..9587ceccb3f 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -16,6 +16,16 @@ class ThemeService */ protected array $listeners = []; + /** + * @var array> + */ + protected array $beforeViews = []; + + /** + * @var array> + */ + protected array $afterViews = []; + /** * Get the currently configured theme. * Returns an empty string if not configured. @@ -82,15 +92,22 @@ public function registerCommand(Command $command): void public function readThemeActions(): void { $themeActionsFile = theme_path('functions.php'); - if ($themeActionsFile && file_exists($themeActionsFile)) { - try { - require $themeActionsFile; - } catch (\Error $exception) { - throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}"); - } + try { + require $themeActionsFile; + } catch (\Error $exception) { + throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}"); } } + /** + * Check if a logical theme is active. + */ + public function logicalThemeIsActive(): bool + { + $themeActionsFile = theme_path('functions.php'); + return $themeActionsFile && file_exists($themeActionsFile); + } + /** * Register any extra paths for where we may expect views to be located * with the provided FileViewFinder, to make custom views available for use. @@ -108,4 +125,63 @@ public function addSocialDriver(string $driverName, array $config, string $socia $driverManager = app()->make(SocialDriverManager::class); $driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect); } + + /** + * Provide the response for a blade template view include. + */ + public function handleViewInclude(string $viewPath, array $data = []): string + { + $viewsContent = [ + ...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data), + view()->make($viewPath, $data)->render(), + ...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data), + ]; + + return implode("\n", $viewsContent); + } + + /** + * Register a custom view to be rendered before the given target view is included in the template system. + */ + public function registerViewRenderBefore(string $targetView, string $localView, int $priority = 50): void + { + $this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority); + } + + /** + * Register a custom view to be rendered after the given target view is included in the template system. + */ + public function registerViewRenderAfter(string $targetView, string $localView, int $priority = 50): void + { + $this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority); + } + + protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void + { + $viewPath = theme_path($localView . '.blade.php'); + if (!file_exists($viewPath)) { + throw new ThemeException("Expected registered view file at \"{$viewPath}\" does not exist"); + } + + if (!isset($location[$targetView])) { + $location[$targetView] = []; + } + $location[$targetView][$viewPath] = $priority; + } + + /** + * @param array $viewSet + * @return string[] + */ + protected function renderViewSets(array $viewSet, array $data): array + { + $paths = array_keys($viewSet); + usort($paths, function (string $a, string $b) use ($viewSet) { + return $viewSet[$a] <=> $viewSet[$b]; + }); + + return array_map(function (string $viewPath) use ($data) { + return view()->file($viewPath, $data)->render(); + }, $paths); + } } From 9fcfc762ec9bf36b173a002fccbc702eeba410b3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 27 Jan 2026 00:36:35 +0000 Subject: [PATCH 3/4] Theme: Added testing of registerViewToRender* functions Updated function name also. --- app/App/helpers.php | 3 +-- app/Theming/ThemeService.php | 4 ++-- tests/ThemeTest.php | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/App/helpers.php b/app/App/helpers.php index 0e357e36aee..8f210ecafd4 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -81,8 +81,7 @@ function setting(?string $key = null, mixed $default = null): mixed /** * Get a path to a theme resource. - * Returns null if a theme is not configured and - * therefore a full path is not available for use. + * Returns null if a theme is not configured, and therefore a full path is not available for use. */ function theme_path(string $path = ''): ?string { diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 9587ceccb3f..0a6327af85a 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -143,7 +143,7 @@ public function handleViewInclude(string $viewPath, array $data = []): string /** * Register a custom view to be rendered before the given target view is included in the template system. */ - public function registerViewRenderBefore(string $targetView, string $localView, int $priority = 50): void + public function registerViewToRenderBefore(string $targetView, string $localView, int $priority = 50): void { $this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority); } @@ -151,7 +151,7 @@ public function registerViewRenderBefore(string $targetView, string $localView, /** * Register a custom view to be rendered after the given target view is included in the template system. */ - public function registerViewRenderAfter(string $targetView, string $localView, int $priority = 50): void + public function registerViewToRenderAfter(string $targetView, string $localView, int $priority = 50): void { $this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority); } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 841ff78caf0..014f3a92f18 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -492,6 +492,38 @@ public function test_public_folder_contents_accessible_via_route() }); } + public function test_register_view_to_render_before_and_after() + { + $this->usingThemeFolder(function (string $folder) { + $before = 'this-is-my-before-header-string'; + $afterA = 'this-is-my-after-header-string-a'; + $afterB = 'this-is-my-after-header-string-b'; + $afterC = 'this-is-my-after-header-string-{{ 1+51 }}'; + + $functionsContent = <<<'CONTENT' +refreshApplication(); + + $resp = $this->get('/login'); + $resp->assertSee($before); + // Ensure ordering of the multiple after views + $resp->assertSee($afterB . "\n" . $afterA . "\nthis-is-my-after-header-string-52"); + }); + } + protected function usingThemeFolder(callable $callback) { // Create a folder and configure a theme From 1b17bb3929d35410bfb5ed80f15bad911f84e832 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 27 Jan 2026 16:50:50 +0000 Subject: [PATCH 4/4] Theme: Changed how before/after views are registered Changed the system out to be a theme event instead of method, to align with other registration events, and so that the theme view work can better be contained in its own class. --- app/App/Providers/ThemeServiceProvider.php | 21 +++-- app/Theming/ThemeEvents.php | 10 +++ app/Theming/ThemeService.php | 91 +------------------- app/Theming/ThemeViews.php | 96 ++++++++++++++++++++++ tests/ThemeTest.php | 17 ++-- 5 files changed, 135 insertions(+), 100 deletions(-) create mode 100644 app/Theming/ThemeViews.php diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 98ad509f355..e32f90b9afe 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -4,9 +4,9 @@ use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeService; +use BookStack\Theming\ThemeViews; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; -use Illuminate\View\View; class ThemeServiceProvider extends ServiceProvider { @@ -26,16 +26,21 @@ public function boot(): void { // Boot up the theme system $themeService = $this->app->make(ThemeService::class); - $viewFactory = $this->app->make('view'); - $themeService->registerViewPathsForTheme($viewFactory->getFinder()); + if (!$themeService->getTheme()) { + return; + } + + $themeService->readThemeActions(); + $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); - if ($themeService->logicalThemeIsActive()) { - $themeService->readThemeActions(); - $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); - $viewFactory->share('__theme', $themeService); + $themeViews = new ThemeViews(); + $themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews); + $themeViews->registerViewPathsForTheme($viewFactory->getFinder()); + if ($themeViews->hasRegisteredViews()) { + $viewFactory->share('__themeViews', $themeViews); Blade::directive('include', function ($expression) { - return "handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>"; + return "handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>"; }); } } diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 44630acaeb1..c6266b32b9c 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -134,6 +134,16 @@ class ThemeEvents */ const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth'; + + /** + * Theme register views event. + * Called by the theme system when a theme is active, so that custom view templates can be registered + * to be rendered in addition to existing app views. + * + * @param \BookStack\Theming\ThemeViews $themeViews + */ + const THEME_REGISTER_VIEWS = 'theme_register_views'; + /** * Web before middleware action. * Runs before the request is handled but after all other middleware apart from those diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 0a6327af85a..14281adca30 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -16,16 +16,6 @@ class ThemeService */ protected array $listeners = []; - /** - * @var array> - */ - protected array $beforeViews = []; - - /** - * @var array> - */ - protected array $afterViews = []; - /** * Get the currently configured theme. * Returns an empty string if not configured. @@ -92,6 +82,10 @@ public function registerCommand(Command $command): void public function readThemeActions(): void { $themeActionsFile = theme_path('functions.php'); + if (!$themeActionsFile || !file_exists($themeActionsFile)) { + return; + } + try { require $themeActionsFile; } catch (\Error $exception) { @@ -99,24 +93,6 @@ public function readThemeActions(): void } } - /** - * Check if a logical theme is active. - */ - public function logicalThemeIsActive(): bool - { - $themeActionsFile = theme_path('functions.php'); - return $themeActionsFile && file_exists($themeActionsFile); - } - - /** - * Register any extra paths for where we may expect views to be located - * with the provided FileViewFinder, to make custom views available for use. - */ - public function registerViewPathsForTheme(FileViewFinder $finder): void - { - $finder->prependLocation(theme_path()); - } - /** * @see SocialDriverManager::addSocialDriver */ @@ -125,63 +101,4 @@ public function addSocialDriver(string $driverName, array $config, string $socia $driverManager = app()->make(SocialDriverManager::class); $driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect); } - - /** - * Provide the response for a blade template view include. - */ - public function handleViewInclude(string $viewPath, array $data = []): string - { - $viewsContent = [ - ...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data), - view()->make($viewPath, $data)->render(), - ...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data), - ]; - - return implode("\n", $viewsContent); - } - - /** - * Register a custom view to be rendered before the given target view is included in the template system. - */ - public function registerViewToRenderBefore(string $targetView, string $localView, int $priority = 50): void - { - $this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority); - } - - /** - * Register a custom view to be rendered after the given target view is included in the template system. - */ - public function registerViewToRenderAfter(string $targetView, string $localView, int $priority = 50): void - { - $this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority); - } - - protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void - { - $viewPath = theme_path($localView . '.blade.php'); - if (!file_exists($viewPath)) { - throw new ThemeException("Expected registered view file at \"{$viewPath}\" does not exist"); - } - - if (!isset($location[$targetView])) { - $location[$targetView] = []; - } - $location[$targetView][$viewPath] = $priority; - } - - /** - * @param array $viewSet - * @return string[] - */ - protected function renderViewSets(array $viewSet, array $data): array - { - $paths = array_keys($viewSet); - usort($paths, function (string $a, string $b) use ($viewSet) { - return $viewSet[$a] <=> $viewSet[$b]; - }); - - return array_map(function (string $viewPath) use ($data) { - return view()->file($viewPath, $data)->render(); - }, $paths); - } } diff --git a/app/Theming/ThemeViews.php b/app/Theming/ThemeViews.php new file mode 100644 index 00000000000..719f8e3ce24 --- /dev/null +++ b/app/Theming/ThemeViews.php @@ -0,0 +1,96 @@ +> + */ + protected array $beforeViews = []; + + /** + * @var array> + */ + protected array $afterViews = []; + + /** + * Register any extra paths for where we may expect views to be located + * with the provided FileViewFinder, to make custom views available for use. + */ + public function registerViewPathsForTheme(FileViewFinder $finder): void + { + $finder->prependLocation(theme_path()); + } + + /** + * Provide the response for a blade template view include. + */ + public function handleViewInclude(string $viewPath, array $data = []): string + { + if (!$this->hasRegisteredViews()) { + return view()->make($viewPath, $data)->render(); + } + + $viewsContent = [ + ...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data), + view()->make($viewPath, $data)->render(), + ...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data), + ]; + + return implode("\n", $viewsContent); + } + + /** + * Register a custom view to be rendered before the given target view is included in the template system. + */ + public function renderBefore(string $targetView, string $localView, int $priority = 50): void + { + $this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority); + } + + /** + * Register a custom view to be rendered after the given target view is included in the template system. + */ + public function renderAfter(string $targetView, string $localView, int $priority = 50): void + { + $this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority); + } + + public function hasRegisteredViews(): bool + { + return !empty($this->beforeViews) && !empty($this->afterViews); + } + + protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void + { + $viewPath = theme_path($localView . '.blade.php'); + if (!file_exists($viewPath)) { + throw new ThemeException("Expected registered view file at \"{$viewPath}\" does not exist"); + } + + if (!isset($location[$targetView])) { + $location[$targetView] = []; + } + $location[$targetView][$viewPath] = $priority; + } + + /** + * @param array $viewSet + * @return string[] + */ + protected function renderViewSets(array $viewSet, array $data): array + { + $paths = array_keys($viewSet); + usort($paths, function (string $a, string $b) use ($viewSet) { + return $viewSet[$a] <=> $viewSet[$b]; + }); + + return array_map(function (string $viewPath) use ($data) { + return view()->file($viewPath, $data)->render(); + }, $paths); + } +} diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 014f3a92f18..f640513cf1d 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -492,7 +492,7 @@ public function test_public_folder_contents_accessible_via_route() }); } - public function test_register_view_to_render_before_and_after() + public function test_theme_register_views_event_to_insert_views_before_and_after() { $this->usingThemeFolder(function (string $folder) { $before = 'this-is-my-before-header-string'; @@ -502,10 +502,14 @@ public function test_register_view_to_render_before_and_after() $functionsContent = <<<'CONTENT' renderBefore('layouts.parts.header', 'before', 4); + $themeViews->renderAfter('layouts.parts.header', 'after-a', 4); + $themeViews->renderAfter('layouts.parts.header', 'after-b', 1); + $themeViews->renderAfter('layouts.parts.header', 'after-c', 12); +}); CONTENT; $viewDir = theme_path(); @@ -516,12 +520,15 @@ public function test_register_view_to_render_before_and_after() file_put_contents($viewDir . '/after-c.blade.php', $afterC); $this->refreshApplication(); + $this->artisan('view:clear'); $resp = $this->get('/login'); $resp->assertSee($before); // Ensure ordering of the multiple after views $resp->assertSee($afterB . "\n" . $afterA . "\nthis-is-my-after-header-string-52"); }); + + $this->artisan('view:clear'); } protected function usingThemeFolder(callable $callback)