diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php
index 88c4612ca61..7b9210a176b 100644
--- a/app/Config/setting-defaults.php
+++ b/app/Config/setting-defaults.php
@@ -32,6 +32,7 @@
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false,
'registration-enabled' => false,
+ 'enable-mermaid' => 'disabled',
// User-level default settings
'user' => [
diff --git a/app/Plugins/MermaidProvider.php b/app/Plugins/MermaidProvider.php
new file mode 100644
index 00000000000..26551f687f0
--- /dev/null
+++ b/app/Plugins/MermaidProvider.php
@@ -0,0 +1,63 @@
+ 'application/vnd.github+json',
+ 'User-Agent' => 'BookStack',
+ ])->get(MermaidProvider::MERMAID_REPOSITORY);
+
+ if ($response->successful()) {
+ $versions = collect($response->json())
+ ->pluck('name')
+ ->all();
+
+ Cache::put($cacheKey, $versions, now()->addHours(12));
+
+ return array_merge(['disabled', 'latest'], $versions);
+ }
+
+ return ['disabled', 'latest'];
+ }
+
+ /**
+ * Get the MermaidJS CDN URI to use.
+ */
+ public function getMermaidJsCdnUri(): string
+ {
+ $mermaidJsVersion = setting('enable-mermaid');
+
+ if ($mermaidJsVersion === 'disabled') {
+ return '';
+ }
+
+ $localPath = public_path("mermaid/mermaid.min.js");
+
+ if (file_exists($localPath)) {
+ return asset("mermaid/mermaid.min.js");
+ }
+
+ return '';
+ }
+
+}
diff --git a/app/Settings/Plugins/MermaidController.php b/app/Settings/Plugins/MermaidController.php
new file mode 100644
index 00000000000..79238d211f1
--- /dev/null
+++ b/app/Settings/Plugins/MermaidController.php
@@ -0,0 +1,57 @@
+input('version');
+
+ if (!$version || $version == 'disabled') {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'No version specified.'
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ $remoteUrl = sprintf(MermaidProvider::MERMAID_CDN, $version);
+ $localDirectory = public_path('mermaid');
+ $localFilename = "mermaid.min.js";
+ $localPath = $localDirectory . '/' . $localFilename;
+
+ if (!File::exists($localDirectory)) {
+ File::makeDirectory($localDirectory, 0755, true);
+ }
+
+ try {
+ $response = Http::get($remoteUrl);
+
+ if ($response->successful()) {
+ File::put($localPath, $response->body());
+
+ return response()->json([
+ 'success' => true,
+ 'path' => asset("mermaid/$localFilename")
+ ]);
+ } else {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Remote file not found.'
+ ], Response::HTTP_NOT_FOUND);
+ }
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ], Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+ }
+}
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 82a4ade5df5..5a984d90639 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -46,6 +46,8 @@
'app_disable_comments' => 'Disable Comments',
'app_disable_comments_toggle' => 'Disable comments',
'app_disable_comments_desc' => 'Disables comments across all pages in the application.
Existing comments are not shown.',
+ 'enable_mermaid' => 'Mermaid.js Version',
+ 'enable_mermaid_desc' => 'Select the version of Mermaid.js to use when rendering diagrams. This allows compatibility with different diagram syntax versions and helps ensure consistent behavior across pages. Updating to a newer version may enable new features or fix rendering issues, but could also affect existing diagrams.',
// Color settings
'color_scheme' => 'Application Color Scheme',
diff --git a/resources/views/exports/parts/mermaid-js.blade.php b/resources/views/exports/parts/mermaid-js.blade.php
new file mode 100644
index 00000000000..876b6f39176
--- /dev/null
+++ b/resources/views/exports/parts/mermaid-js.blade.php
@@ -0,0 +1,24 @@
+@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider')
+
+@if(setting('enable-mermaid') != 'disabled')
+
+
+
+@endif
\ No newline at end of file
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index a6d908fc06c..b060261d3cb 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -43,6 +43,9 @@ class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''
@stack('translations')
+
+
+ @include('layouts.parts.mermaid-js')
getForCurrentUser('ui-shortcuts-enabled', false))
diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php
index 4a55e034c5d..4ffdca2fbf0 100644
--- a/resources/views/layouts/export.blade.php
+++ b/resources/views/layouts/export.blade.php
@@ -10,6 +10,8 @@
@include('exports.parts.styles', ['format' => $format, 'engine' => $engine ?? ''])
@include('exports.parts.custom-head')
+
+ @include('layouts.parts.mermaid-js')
@include('layouts.parts.export-body-start')
diff --git a/resources/views/layouts/parts/mermaid-js.blade.php b/resources/views/layouts/parts/mermaid-js.blade.php
new file mode 100644
index 00000000000..c25eac038f6
--- /dev/null
+++ b/resources/views/layouts/parts/mermaid-js.blade.php
@@ -0,0 +1,25 @@
+@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider')
+
+@if(setting('enable-mermaid') != 'disabled' && !request()->routeIs('settings.category'))
+
+
+
+
+@endif
\ No newline at end of file
diff --git a/resources/views/layouts/plain.blade.php b/resources/views/layouts/plain.blade.php
index 37ebbe83d94..67d23faf191 100644
--- a/resources/views/layouts/plain.blade.php
+++ b/resources/views/layouts/plain.blade.php
@@ -15,6 +15,9 @@ class="@yield('document-class')">
@include('layouts.parts.custom-styles')
@include('layouts.parts.custom-head')
+
+
+ @include('layouts.parts.mermaid-js')
@yield('content')
diff --git a/resources/views/settings/categories/customization.blade.php b/resources/views/settings/categories/customization.blade.php
index 70a490298c7..070fca10839 100644
--- a/resources/views/settings/categories/customization.blade.php
+++ b/resources/views/settings/categories/customization.blade.php
@@ -1,5 +1,27 @@
@extends('settings.layout')
+@inject('mermaidProvider', 'BookStack\Plugins\MermaidProvider')
+
+
+
+
@section('card')
{{ trans('settings.app_customization') }}
+
+
@endsection
@section('after-content')
diff --git a/routes/web.php b/routes/web.php
index ea3efe1ac77..8d25f343e3c 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,25 +1,26 @@
name('settings');
Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category');
Route::post('/settings/{category}', [SettingControllers\SettingController::class, 'update']);
+ Route::post('/settings/plugins/mermaid/download', [SettingControllers\Plugins\MermaidController::class, 'store'])->name('settings.plugins.mermaid.download');
});
// MFA routes
diff --git a/tests/MermaidHeaderTest.php b/tests/MermaidHeaderTest.php
new file mode 100644
index 00000000000..6818cfb2c9c
--- /dev/null
+++ b/tests/MermaidHeaderTest.php
@@ -0,0 +1,56 @@
+setSettings(['enable-mermaid' => '1111']);
+
+ $page = $this->entities->pageWithinChapter();
+ $resp = $this->asAdmin()->get($page->getUrl());
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+ $nonce = app()->make(CspService::class)->getNonce();
+ $this->assertStringContainsString('nonce-'.$nonce, $scriptHeader);
+ $resp->assertSee(
+ '',
+ false
+ );
+ }
+ public function test_script_mermaid_not_include_if_setting_value_is_disabled()
+ {
+ $this->setSettings(['enable-mermaid' => 'disabled']);
+
+ $page = $this->entities->pageWithinChapter();
+ $resp = $this->asAdmin()->get($page->getUrl());
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+ $nonce = app()->make(CspService::class)->getNonce();
+ $this->assertStringContainsString('nonce-'.$nonce, $scriptHeader);
+ $resp->assertDontSee(
+ '',
+ false
+ );
+ }
+
+ /**
+ * Get the value of the first CSP header of the given type.
+ */
+ protected function getCspHeader(TestResponse $resp, string $type): string
+ {
+ $cspHeaders = explode('; ', $resp->headers->get('Content-Security-Policy'));
+
+ foreach ($cspHeaders as $cspHeader) {
+ if (strpos($cspHeader, $type) === 0) {
+ return $cspHeader;
+ }
+ }
+
+ return '';
+ }
+}
\ No newline at end of file
diff --git a/tests/Plugins/MermaidProviderTest.php b/tests/Plugins/MermaidProviderTest.php
new file mode 100644
index 00000000000..519643923d1
--- /dev/null
+++ b/tests/Plugins/MermaidProviderTest.php
@@ -0,0 +1,111 @@
+ Http::response([
+ ['name' => 'v10.0.0'],
+ ['name' => 'v9.4.0'],
+ ]),
+ ]);
+
+ $provider = new MermaidProvider();
+ $versions = $provider->getMermaidVersions();
+
+ $this->assertContains('disabled', $versions);
+ $this->assertContains('latest', $versions);
+ $this->assertContains('v10.0.0', $versions);
+ $this->assertContains('v9.4.0', $versions);
+ }
+
+ public function test_returns_cached_version_if_available()
+ {
+ Cache::shouldReceive('get')
+ ->once()
+ ->with('git_mermaid_versions')
+ ->andReturn(['v1.0.0', 'v2.0.0']);
+
+ $provider = new MermaidProvider();
+ $versions = $provider->getMermaidVersions();
+
+ $this->assertEquals(['disabled', 'latest', 'v1.0.0', 'v2.0.0'], $versions);
+ }
+
+ public function test_fetches_versions_and_caches_on_successful_response()
+ {
+ Cache::shouldReceive('get')
+ ->once()
+ ->with('git_mermaid_versions')
+ ->andReturn(null);
+
+ Cache::shouldReceive('put')
+ ->once()
+ ->withArgs(function ($key, $value, $ttl) {
+ return $key === 'git_mermaid_versions'
+ && in_array('v10.0.0', $value)
+ && $ttl->greaterThan(now());
+ });
+
+ Http::fake([
+ MermaidProvider::MERMAID_REPOSITORY => Http::response([
+ ['name' => 'v10.0.0'],
+ ['name' => 'v9.4.0'],
+ ]),
+ ]);
+
+ $provider = new MermaidProvider();
+ $versions = $provider->getMermaidVersions();
+
+ $this->assertContains('v10.0.0', $versions);
+ $this->assertContains('disabled', $versions);
+ $this->assertContains('latest', $versions);
+ }
+
+ public function test_does_not_cache_on_failed_response()
+ {
+ Cache::shouldReceive('get')
+ ->once()
+ ->with('git_mermaid_versions')
+ ->andReturn(null);
+
+ Cache::shouldReceive('put')->never();
+
+ Http::fake([
+ '*' => Http::response(null, 500),
+ ]);
+
+ $provider = new MermaidProvider();
+ $versions = $provider->getMermaidVersions();
+
+ $this->assertEquals(['disabled', 'latest'], $versions);
+ }
+
+ public function test_returns_correct_mermaid_cdn_uri()
+ {
+ $this->setSettings(['enable-mermaid' => '10.1.0']);
+
+ $provider = new MermaidProvider();
+ $uri = $provider->getMermaidJsCdnUri();
+
+ $this->assertEquals('http://localhost/mermaid/mermaid.min.js', $uri);
+ }
+
+ public function test_returns_empty_string_if_mermaid_disabled()
+ {
+ $this->setSettings(['enable-mermaid' => 'disabled']);
+
+ $provider = new MermaidProvider();
+ $uri = $provider->getMermaidJsCdnUri();
+
+ $this->assertEquals('', $uri);
+ }
+}
\ No newline at end of file