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
18 changes: 18 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,23 @@ 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->loadModules();
$themeService->readThemeActions();
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);

$themeViews = new ThemeViews();
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
$themeViews->registerViewPathsForTheme($viewFactory->getFinder(), $themeService->getModules());
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
7 changes: 4 additions & 3 deletions app/Theming/ThemeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@
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);
}

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
56 changes: 56 additions & 0 deletions app/Theming/ThemeModule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace BookStack\Theming;

use BookStack\Exceptions\ThemeException;

class ThemeModule
{
public function __construct(
public readonly string $name,
public readonly string $description,
public readonly string $folderName,
public readonly string $version,
) {
}

/**
* Create a ThemeModule instance from JSON data.
*
* @throws ThemeException
*/
public static function fromJson(array $data, string $folderName): self
{
if (empty($data['name']) || !is_string($data['name'])) {
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
}

if (!isset($data['description']) || !is_string($data['description'])) {
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
}

if (!isset($data['version']) || !is_string($data['version'])) {
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
}

if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
throw new ThemeException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
}

return new self(
name: $data['name'],
description: $data['description'],
folderName: $folderName,
version: $data['version'],
);
}

/**
* Get a path for a file within this module.
*/
public function path($path = ''): string
{
$component = trim($path, '/');
return theme_path("modules/{$this->folderName}/{$component}");
}
}
92 changes: 87 additions & 5 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 All @@ -15,6 +16,11 @@ class ThemeService
*/
protected array $listeners = [];

/**
* @var array<string, ThemeModule>
*/
protected array $modules = [];

/**
* Get the currently configured theme.
* Returns an empty string if not configured.
Expand Down Expand Up @@ -76,20 +82,96 @@ public function registerCommand(Command $command): void
}

/**
* 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 $themeActionsFile;
require $functionFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
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;
}

$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<string, ThemeModule>
*/
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
*/
Expand Down
104 changes: 104 additions & 0 deletions app/Theming/ThemeViews.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?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.
* @param ThemeModule[] $modules
*/
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());
}

/**
* 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);
}
}
Loading