From 151823b84e227e6ed63a216ff970ce2fa5491fca Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Mar 2026 10:26:00 +0000 Subject: [PATCH] Theme Modules: Added easier way to insert HTML head content --- app/Theming/CustomHtmlHeadContentProvider.php | 25 +++++++++++++++++-- app/Theming/ThemeService.php | 14 +++++++++++ dev/docs/theme-system-modules.md | 1 + .../views/layouts/parts/custom-head.blade.php | 2 +- tests/Theme/ThemeModuleTest.php | 18 +++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php index 9f794a077..209070997 100644 --- a/app/Theming/CustomHtmlHeadContentProvider.php +++ b/app/Theming/CustomHtmlHeadContentProvider.php @@ -12,7 +12,8 @@ class CustomHtmlHeadContentProvider { public function __construct( protected CspService $cspService, - protected Cache $cache + protected Cache $cache, + protected ThemeService $themeService, ) { } @@ -23,8 +24,9 @@ class CustomHtmlHeadContentProvider public function forWeb(): string { $content = $this->getSourceContent(); - $hash = md5($content); + $hash = md5($content) . ':' . $this->themeService->getModulesHash(); $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function () use ($content) { + $content .= "\n" . $this->getModuleHeadContent(); return HtmlNonceApplicator::prepare($content); }); @@ -53,4 +55,23 @@ class CustomHtmlHeadContentProvider { return setting('app-custom-head', ''); } + + /** + * Get any custom head content from installed modules. + */ + protected function getModuleHeadContent(): string + { + $content = ''; + foreach ($this->themeService->getModules() as $module) { + $headContentPath = $module->path('head'); + if (file_exists($headContentPath) && is_dir($headContentPath)) { + $htmlFiles = glob($headContentPath . '/*.html'); + foreach ($htmlFiles as $file) { + $content .= file_get_contents($file); + } + } + } + + return $content; + } } diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 6013bb558..864061c1c 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -126,6 +126,20 @@ class ThemeService return $this->modules; } + /** + * Get a hash to represent the currently loaded modules. + */ + public function getModulesHash(): string + { + $key = ""; + + foreach ($this->modules as $module) { + $key .= $module->name . ':' . $module->version . ';'; + } + + return md5($key); + } + /** * Look for a specific file within the theme or its modules. * Returns the first file found or null if not found. diff --git a/dev/docs/theme-system-modules.md b/dev/docs/theme-system-modules.md index 10eec2275..8aa9370ed 100644 --- a/dev/docs/theme-system-modules.md +++ b/dev/docs/theme-system-modules.md @@ -24,6 +24,7 @@ The content within the module folder should then follow this format: - `bookstack-module.json` - REQUIRED - A JSON file containing [the metadata](#module-json-metadata) for the module. - `functions.php` - OPTIONAL - A PHP file containing code for the [logical theme system](logical-theme-system.md). +- `head/` - OPTIONAL - A folder containing HTML files which will be included into the HTML head of app-views. - `icons/` - OPTIONAL - A folder containing any icons to use as per [the visual theme system](visual-theme-system.md#customizing-icons). - `lang/` - OPTIONAL - A folder containing any language files to use as per [the visual theme system](visual-theme-system.md#customizing-text-content). - `public/` - OPTIONAL - A folder containing any files to expose into public web-space as per [the visual theme system](visual-theme-system.md#publicly-accessible-files). diff --git a/resources/views/layouts/parts/custom-head.blade.php b/resources/views/layouts/parts/custom-head.blade.php index a13215cf8..caa177030 100644 --- a/resources/views/layouts/parts/custom-head.blade.php +++ b/resources/views/layouts/parts/custom-head.blade.php @@ -1,6 +1,6 @@ @inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider') -@if(setting('app-custom-head') && !request()->routeIs('settings.category')) +@if(!request()->routeIs('settings.category')) {!! $headContent->forWeb() !!} diff --git a/tests/Theme/ThemeModuleTest.php b/tests/Theme/ThemeModuleTest.php index b2f912dd7..d1c7225b6 100644 --- a/tests/Theme/ThemeModuleTest.php +++ b/tests/Theme/ThemeModuleTest.php @@ -3,6 +3,7 @@ namespace Tests\Theme; use BookStack\Facades\Theme; +use BookStack\Util\CspService; use Tests\TestCase; class ThemeModuleTest extends TestCase @@ -220,6 +221,23 @@ class ThemeModuleTest extends TestCase }); } + public function test_module_can_provide_head_content() + { + $this->usingModuleFolder(function (string $moduleFolderPath) { + mkdir($moduleFolderPath . '/head', 0777, true); + file_put_contents($moduleFolderPath . '/head/hello.html', ''); + + $this->refreshApplication(); + + $cspService = $this->app->make(CspService::class); + $nonce = $cspService->getNonce(); + + $resp = $this->asAdmin()->get('/'); + $resp->assertSee('', false); + $resp->assertSee('', false); + }); + } + protected function usingModuleFolder(callable $callback): void { $this->usingThemeFolder(function (string $themeFolder) use ($callback) {