Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/App/Providers/ThemeServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use BookStack\Theming\ThemeViews;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;

class ThemeServiceProvider extends ServiceProvider
Expand All @@ -24,7 +26,22 @@ public function boot(): void
{
// Boot up the theme system
$themeService = $this->app->make(ThemeService::class);
$viewFactory = $this->app->make('view');
if (!$themeService->getTheme()) {
return;
}

$themeService->readThemeActions();
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);

$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 "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
});
}
}
}
3 changes: 1 addition & 2 deletions app/App/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
8 changes: 1 addition & 7 deletions app/Config/view.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/Theming/ThemeEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions app/Theming/ThemeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,12 +82,14 @@ 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()}");
}
if (!$themeActionsFile || !file_exists($themeActionsFile)) {
return;
}

try {
require $themeActionsFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
}
}

Expand Down
96 changes: 96 additions & 0 deletions app/Theming/ThemeViews.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace BookStack\Theming;

use BookStack\Exceptions\ThemeException;
use Illuminate\View\FileViewFinder;

class ThemeViews
{
/**
* @var array<string, array<string, int>>
*/
protected array $beforeViews = [];

/**
* @var array<string, array<string, int>>
*/
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<string, int> $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);
}
}
39 changes: 39 additions & 0 deletions tests/ThemeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,45 @@ public function test_public_folder_contents_accessible_via_route()
});
}

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';
$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'
<?php use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeViews;
Theme::listen(ThemeEvents::THEME_REGISTER_VIEWS, function (ThemeViews $themeViews) {
$themeViews->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();
file_put_contents($viewDir . '/functions.php', $functionsContent);
file_put_contents($viewDir . '/before.blade.php', $before);
file_put_contents($viewDir . '/after-a.blade.php', $afterA);
file_put_contents($viewDir . '/after-b.blade.php', $afterB);
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)
{
// Create a folder and configure a theme
Expand Down