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