Theme System: Added initial module implementations

This commit is contained in:
Dan Brown
2026-02-01 11:53:46 +00:00
parent 1b17bb3929
commit 4949520194
7 changed files with 168 additions and 25 deletions

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -0,0 +1,50 @@
<?php
namespace BookStack\Theming;
use BookStack\Exceptions\ThemeException;
class ThemeModule
{
protected string $name;
protected string $description;
protected string $folderName;
protected int $version;
/**
* Create a ThemeModule instance from JSON data.
*
* @throws ThemeException
*/
public static function fromJson(array $data, string $folderName): static
{
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_int($data['version']) || $data['version'] < 1) {
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
}
$module = new static();
$module->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}");
}
}

View File

@@ -16,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.
@@ -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<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
*/

View File

@@ -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());
}

View File

@@ -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);

View File

@@ -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 '';
}