diff --git a/app/Theming/ThemeModule.php b/app/Theming/ThemeModule.php
index f873ed247..594aa0701 100644
--- a/app/Theming/ThemeModule.php
+++ b/app/Theming/ThemeModule.php
@@ -6,17 +6,20 @@ use BookStack\Exceptions\ThemeException;
class ThemeModule
{
- protected string $name;
- protected string $description;
- protected string $folderName;
- protected string $version;
+ 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): static
+ 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");
@@ -34,13 +37,12 @@ class ThemeModule
throw new ThemeException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
}
- $module = new static();
- $module->name = $data['name'];
- $module->description = $data['description'];
- $module->folderName = $folderName;
- $module->version = $data['version'];
-
- return $module;
+ return new self(
+ name: $data['name'],
+ description: $data['description'],
+ folderName: $folderName,
+ version: $data['version'],
+ );
}
/**
diff --git a/tests/Theme/ThemeModuleTests.php b/tests/Theme/ThemeModuleTests.php
new file mode 100644
index 000000000..a7d317dce
--- /dev/null
+++ b/tests/Theme/ThemeModuleTests.php
@@ -0,0 +1,223 @@
+usingThemeFolder(function ($themeFolder) {
+ $a = theme_path('modules/a');
+ $b = theme_path('modules/b');
+ mkdir($a, 0777, true);
+ mkdir($b, 0777, true);
+
+ file_put_contents($a . '/bookstack-module.json', json_encode([
+ 'name' => 'Module A',
+ 'description' => 'This is module A',
+ 'version' => '1.0.0',
+ ]));
+ file_put_contents($b . '/bookstack-module.json', json_encode([
+ 'name' => 'Module B',
+ 'description' => 'This is module B',
+ 'version' => 'v0.5.0',
+ ]));
+
+ $this->refreshApplication();
+
+ $modules = Theme::getModules();
+ $this->assertCount(2, $modules);
+
+ $moduleA = $modules['a'];
+ $this->assertEquals('Module A', $moduleA->name);
+ $this->assertEquals('This is module A', $moduleA->description);
+ $this->assertEquals('1.0.0', $moduleA->version);
+ });
+ }
+
+ public function test_module_not_loaded_if_no_bookstack_module_json()
+ {
+ $this->usingThemeFolder(function ($themeFolder) {
+ $moduleDir = theme_path('/modules/a');
+ mkdir($moduleDir, 0777, true);
+ file_put_contents($moduleDir . '/module.json', '{}');
+ $this->refreshApplication();
+ $modules = Theme::getModules();
+ $this->assertCount(0, $modules);
+ });
+ }
+
+ public function test_language_text_overridable_via_module()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $translationPath = $moduleFolderPath . '/lang/en';
+ mkdir($translationPath, 0777, true);
+ file_put_contents($translationPath . '/entities.php', ' "SuperBeans"];');
+ $this->refreshApplication();
+
+ $this->asAdmin()->get('/books')->assertSee('SuperBeans');
+ });
+ }
+
+ public function test_language_files_merge_with_theme_files_with_theme_taking_precedence()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $moduleTranslationPath = $moduleFolderPath . '/lang/en';
+ mkdir($moduleTranslationPath, 0777, true);
+ file_put_contents($moduleTranslationPath . '/entities.php', ' "SuperBeans", "recently_viewed" => "ViewedBiscuits"];');
+
+ $themeTranslationPath = theme_path('lang/en');
+ mkdir($themeTranslationPath, 0777, true);
+ file_put_contents($themeTranslationPath . '/entities.php', ' "WonderBeans"];');
+ $this->refreshApplication();
+
+ $this->asAdmin()->get('/books')
+ ->assertSee('WonderBeans')
+ ->assertDontSee('SuperBeans')
+ ->assertSee('ViewedBiscuits');
+ });
+ }
+
+ public function test_view_files_overridable_from_module()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $viewsFolder = $moduleFolderPath . '/views/layouts/parts';
+ mkdir($viewsFolder, 0777, true);
+ file_put_contents($viewsFolder . '/header.blade.php', 'My custom header that says badgerriffic');
+ $this->refreshApplication();
+ $this->asAdmin()->get('/')->assertSee('badgerriffic');
+ });
+ }
+
+ public function test_theme_view_files_take_precedence_over_module_view_files()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $viewsFolder = $moduleFolderPath . '/views/layouts/parts';
+ mkdir($viewsFolder, 0777, true);
+ file_put_contents($viewsFolder . '/header.blade.php', 'My custom header that says badgerriffic');
+
+ $themeViewsFolder = theme_path('layouts/parts');
+ mkdir($themeViewsFolder, 0777, true);
+ file_put_contents($themeViewsFolder . '/header.blade.php', 'My theme header that says awesomeferrets');
+
+ $this->refreshApplication();
+ $this->asAdmin()->get('/')
+ ->assertDontSee('badgerriffic')
+ ->assertSee('awesomeferrets');
+ });
+ }
+
+ public function test_theme_and_modules_views_can_be_used_at_the_same_time()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $viewsFolder = $moduleFolderPath . '/views/layouts/parts';
+ mkdir($viewsFolder, 0777, true);
+ file_put_contents($viewsFolder . '/base-body-start.blade.php', 'My custom header that says badgerriffic');
+
+ $themeViewsFolder = theme_path('layouts/parts');
+ mkdir($themeViewsFolder, 0777, true);
+ file_put_contents($themeViewsFolder . '/base-body-end.blade.php', 'My theme header that says awesomeferrets');
+
+ $this->refreshApplication();
+ $this->asAdmin()->get('/')
+ ->assertSee('badgerriffic')
+ ->assertSee('awesomeferrets');
+ });
+ }
+
+ public function test_icons_can_be_overridden_from_module()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $iconsFolder = $moduleFolderPath . '/icons';
+ mkdir($iconsFolder, 0777, true);
+ file_put_contents($iconsFolder . '/books.svg', '');
+ $this->refreshApplication();
+
+ $this->asAdmin()->get('/')->assertSee('supericonpath', false);
+ });
+ }
+
+ public function test_theme_icons_take_precedence_over_module_icons()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $iconsFolder = $moduleFolderPath . '/icons';
+ mkdir($iconsFolder, 0777, true);
+ file_put_contents($iconsFolder . '/books.svg', '');
+ $this->refreshApplication();
+
+ $themeViewsFolder = theme_path('icons');
+ mkdir($themeViewsFolder, 0777, true);
+ file_put_contents($themeViewsFolder . '/books.svg', '');
+
+
+ $this->asAdmin()->get('/')
+ ->assertSee('wackyiconpath', false)
+ ->assertDontSee('supericonpath', false);
+ });
+ }
+
+ public function test_public_folder_can_be_provided_from_module()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $publicFolder = $moduleFolderPath . '/public';
+ mkdir($publicFolder, 0777, true);
+ $themeName = basename(dirname(dirname($moduleFolderPath)));
+ file_put_contents($publicFolder . '/test.txt', 'hellofrominsidethisfileimaghostwoooo!');
+ $this->refreshApplication();
+
+ $resp = $this->asAdmin()->get("/theme/{$themeName}/test.txt")->streamedContent();
+ $this->assertEquals('hellofrominsidethisfileimaghostwoooo!', $resp);
+ });
+ }
+
+ public function test_theme_public_files_take_precedence_over_modules()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ $publicFolder = $moduleFolderPath . '/public';
+ mkdir($publicFolder, 0777, true);
+ $themeName = basename(theme_path());
+ file_put_contents($publicFolder . '/test.txt', 'hellofrominsidethisfileimaghostwoooo!');
+
+ $themePublicFolder = theme_path('public');
+ mkdir($themePublicFolder, 0777, true);
+ file_put_contents($themePublicFolder . '/test.txt', 'imadifferentghostinsidethetheme,woooooo!');
+
+ $this->refreshApplication();
+
+ $resp = $this->asAdmin()->get("/theme/{$themeName}/test.txt")->streamedContent();
+ $this->assertEquals('imadifferentghostinsidethetheme,woooooo!', $resp);
+ });
+ }
+
+ public function test_logical_functions_file_loaded_from_module_and_it_runs_alongside_theme_functions()
+ {
+ $this->usingModuleFolder(function (string $moduleFolderPath) {
+ file_put_contents($moduleFolderPath . '/functions.php', "alias('cat', 'dog');});");
+
+ $themeFunctionsFile = theme_path('functions.php');
+ file_put_contents($themeFunctionsFile, "alias('beans', 'cheese');});");
+
+ $this->refreshApplication();
+
+ $this->assertEquals('cat', $this->app->getAlias('dog'));
+ $this->assertEquals('beans', $this->app->getAlias('cheese'));
+ });
+ }
+
+ protected function usingModuleFolder(callable $callback): void
+ {
+ $this->usingThemeFolder(function (string $themeFolder) use ($callback) {
+ $moduleFolderPath = theme_path('modules/test-module');
+ mkdir($moduleFolderPath, 0777, true);
+ file_put_contents($moduleFolderPath . '/bookstack-module.json', json_encode([
+ 'name' => 'Test Module',
+ 'description' => 'This is a test module',
+ 'version' => 'v1.0.0',
+ ]));
+ $callback($moduleFolderPath);
+ });
+ }
+}