Skip to content

Commit 4949520

Browse files
committed
Theme System: Added initial module implementations
1 parent 1b17bb3 commit 4949520

File tree

7 files changed

+168
-25
lines changed

7 files changed

+168
-25
lines changed

app/App/Providers/ThemeServiceProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ public function boot(): void
3131
return;
3232
}
3333

34+
$themeService->loadModules();
3435
$themeService->readThemeActions();
3536
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
3637

3738
$themeViews = new ThemeViews();
3839
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
39-
$themeViews->registerViewPathsForTheme($viewFactory->getFinder());
40+
$themeViews->registerViewPathsForTheme($viewFactory->getFinder(), $themeService->getModules());
4041
if ($themeViews->hasRegisteredViews()) {
4142
$viewFactory->share('__themeViews', $themeViews);
4243
Blade::directive('include', function ($expression) {

app/Theming/ThemeController.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@
55
use BookStack\Facades\Theme;
66
use BookStack\Http\Controller;
77
use BookStack\Util\FilePathNormalizer;
8+
use Symfony\Component\HttpFoundation\StreamedResponse;
89

910
class ThemeController extends Controller
1011
{
1112
/**
1213
* Serve a public file from the configured theme.
1314
*/
14-
public function publicFile(string $theme, string $path)
15+
public function publicFile(string $theme, string $path): StreamedResponse
1516
{
1617
$cleanPath = FilePathNormalizer::normalize($path);
1718
if ($theme !== Theme::getTheme() || !$cleanPath) {
1819
abort(404);
1920
}
2021

21-
$filePath = theme_path("public/{$cleanPath}");
22-
if (!file_exists($filePath)) {
22+
$filePath = Theme::findFirstFile("public/{$cleanPath}");
23+
if (!$filePath) {
2324
abort(404);
2425
}
2526

app/Theming/ThemeModule.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace BookStack\Theming;
4+
5+
use BookStack\Exceptions\ThemeException;
6+
7+
class ThemeModule
8+
{
9+
protected string $name;
10+
protected string $description;
11+
protected string $folderName;
12+
protected int $version;
13+
14+
/**
15+
* Create a ThemeModule instance from JSON data.
16+
*
17+
* @throws ThemeException
18+
*/
19+
public static function fromJson(array $data, string $folderName): static
20+
{
21+
if (empty($data['name']) || !is_string($data['name'])) {
22+
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
23+
}
24+
25+
if (!isset($data['description']) || !is_string($data['description'])) {
26+
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
27+
}
28+
29+
if (!isset($data['version']) || !is_int($data['version']) || $data['version'] < 1) {
30+
throw new ThemeException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
31+
}
32+
33+
$module = new static();
34+
$module->name = $data['name'];
35+
$module->description = $data['description'];
36+
$module->folderName = $folderName;
37+
$module->version = $data['version'];
38+
39+
return $module;
40+
}
41+
42+
/**
43+
* Get a path for a file within this module.
44+
*/
45+
public function path($path = ''): string
46+
{
47+
$component = trim($path, '/');
48+
return theme_path("modules/{$this->folderName}/{$component}");
49+
}
50+
}

app/Theming/ThemeService.php

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class ThemeService
1616
*/
1717
protected array $listeners = [];
1818

19+
/**
20+
* @var array<string, ThemeModule>
21+
*/
22+
protected array $modules = [];
23+
1924
/**
2025
* Get the currently configured theme.
2126
* Returns an empty string if not configured.
@@ -77,20 +82,94 @@ public function registerCommand(Command $command): void
7782
}
7883

7984
/**
80-
* Read any actions from the set theme path if the 'functions.php' file exists.
85+
* Read any actions from the 'functions.php' file of the active theme or its modules.
8186
*/
8287
public function readThemeActions(): void
8388
{
84-
$themeActionsFile = theme_path('functions.php');
85-
if (!$themeActionsFile || !file_exists($themeActionsFile)) {
89+
$moduleFunctionFiles = array_map(function (ThemeModule $module): string {
90+
return $module->path('functions.php');
91+
}, $this->modules);
92+
$allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]);
93+
$filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool {
94+
return $file && file_exists($file);
95+
});
96+
97+
foreach ($filteredFunctionFiles as $functionFile) {
98+
try {
99+
require $functionFile;
100+
} catch (\Error $exception) {
101+
throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}");
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Read the modules folder and load in any valid theme modules.
108+
*/
109+
public function loadModules(): void
110+
{
111+
$modulesFolder = theme_path('modules');
112+
if (!$modulesFolder || !is_dir($modulesFolder)) {
86113
return;
87114
}
88115

89-
try {
90-
require $themeActionsFile;
91-
} catch (\Error $exception) {
92-
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
116+
$subFolders = array_filter(scandir($modulesFolder), function ($item) use ($modulesFolder) {
117+
return $item !== '.' && $item !== '..' && is_dir($modulesFolder . DIRECTORY_SEPARATOR . $item);
118+
});
119+
120+
foreach ($subFolders as $folderName) {
121+
$moduleJsonFile = $modulesFolder . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json';
122+
123+
if (!file_exists($moduleJsonFile)) {
124+
continue;
125+
}
126+
127+
try {
128+
$jsonContent = file_get_contents($moduleJsonFile);
129+
$jsonData = json_decode($jsonContent, true);
130+
131+
if (json_last_error() !== JSON_ERROR_NONE) {
132+
throw new ThemeException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg());
133+
}
134+
135+
$module = ThemeModule::fromJson($jsonData, $folderName);
136+
$this->modules[$folderName] = $module;
137+
} catch (ThemeException $exception) {
138+
throw $exception;
139+
} catch (\Exception $exception) {
140+
throw new ThemeException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}");
141+
}
142+
}
143+
}
144+
145+
/**
146+
* Get all loaded theme modules.
147+
* @return array<string, ThemeModule>
148+
*/
149+
public function getModules(): array
150+
{
151+
return $this->modules;
152+
}
153+
154+
/**
155+
* Look for a specific file within the theme or its modules.
156+
* Returns the first file found or null if not found.
157+
*/
158+
public function findFirstFile(string $path): ?string
159+
{
160+
$themePath = theme_path($path);
161+
if (file_exists($themePath)) {
162+
return $themePath;
163+
}
164+
165+
foreach ($this->modules as $module) {
166+
$customizedFile = $module->path($path);
167+
if (file_exists($customizedFile)) {
168+
return $customizedFile;
169+
}
93170
}
171+
172+
return null;
94173
}
95174

96175
/**

app/Theming/ThemeViews.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,17 @@ class ThemeViews
2020
/**
2121
* Register any extra paths for where we may expect views to be located
2222
* with the provided FileViewFinder, to make custom views available for use.
23+
* @param ThemeModule[] $modules
2324
*/
24-
public function registerViewPathsForTheme(FileViewFinder $finder): void
25+
public function registerViewPathsForTheme(FileViewFinder $finder, array $modules): void
2526
{
27+
foreach ($modules as $module) {
28+
$moduleViewsPath = $module->path('views');
29+
if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) {
30+
$finder->prependLocation($moduleViewsPath);
31+
}
32+
}
33+
2634
$finder->prependLocation(theme_path());
2735
}
2836

app/Translation/FileLoader.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace BookStack\Translation;
44

5+
use BookStack\Facades\Theme;
56
use Illuminate\Translation\FileLoader as BaseLoader;
67

78
class FileLoader extends BaseLoader
@@ -12,11 +13,6 @@ class FileLoader extends BaseLoader
1213
* Extends Laravel's translation FileLoader to look in multiple directories
1314
* so that we can load in translation overrides from the theme file if wanted.
1415
*
15-
* Note: As of using Laravel 10, this may now be redundant since Laravel's
16-
* file loader supports multiple paths. This needs further testing though
17-
* to confirm if Laravel works how we expect, since we specifically need
18-
* the theme folder to be able to partially override core lang files.
19-
*
2016
* @param string $locale
2117
* @param string $group
2218
* @param string|null $namespace
@@ -32,9 +28,18 @@ public function load($locale, $group, $namespace = null): array
3228
if (is_null($namespace) || $namespace === '*') {
3329
$themePath = theme_path('lang');
3430
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
35-
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
3631

37-
return array_merge($originalTranslations, $themeTranslations);
32+
$modules = Theme::getModules();
33+
$moduleTranslations = [];
34+
foreach ($modules as $module) {
35+
$modulePath = $module->path('lang');
36+
if (file_exists($modulePath)) {
37+
$moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group));
38+
}
39+
}
40+
41+
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
42+
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
3843
}
3944

4045
return $this->loadNamespaced($locale, $group, $namespace);

app/Util/SvgIcon.php

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace BookStack\Util;
44

5+
use BookStack\Facades\Theme;
6+
57
class SvgIcon
68
{
79
public function __construct(
@@ -23,12 +25,9 @@ public function toHtml(): string
2325
$attrString .= $attrName . '="' . $attr . '" ';
2426
}
2527

26-
$iconPath = resource_path('icons/' . $this->name . '.svg');
27-
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
28-
29-
if ($themeIconPath && file_exists($themeIconPath)) {
30-
$iconPath = $themeIconPath;
31-
} elseif (!file_exists($iconPath)) {
28+
$defaultIconPath = resource_path('icons/' . $this->name . '.svg');
29+
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath;
30+
if (!file_exists($iconPath)) {
3231
return '';
3332
}
3433

0 commit comments

Comments
 (0)