diff --git a/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css b/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css
index 068f64786..8dd77c010 100644
--- a/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css
+++ b/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css
@@ -26,7 +26,7 @@
transition: transform 0.1s ease-out;
}
-.mermaid-rendered svg {
+.mermaid-rendered img {
height: auto;
max-width: 100%;
}
diff --git a/src/Elastic.Documentation.Site/Assets/mermaid.ts b/src/Elastic.Documentation.Site/Assets/mermaid.ts
index 2ec993a2f..976afc73a 100644
--- a/src/Elastic.Documentation.Site/Assets/mermaid.ts
+++ b/src/Elastic.Documentation.Site/Assets/mermaid.ts
@@ -61,7 +61,7 @@ function setupControls(
container: HTMLElement,
viewport: HTMLElement,
rendered: HTMLElement,
- svgContent: string
+ imgSrc: string
): void {
const state: DiagramState = {
zoom: 1,
@@ -131,7 +131,7 @@ function setupControls(
// Fullscreen handler
fullscreenBtn.addEventListener('click', () => {
- openFullscreenModal(svgContent)
+ openFullscreenModal(imgSrc)
})
// Pan with mouse drag
@@ -200,7 +200,7 @@ function calculateFitScale(
/**
* Open fullscreen modal with the diagram
*/
-function openFullscreenModal(svgContent: string): void {
+function openFullscreenModal(imgSrc: string): void {
// Create modal elements
const modal = document.createElement('div')
modal.className = 'mermaid-modal'
@@ -219,7 +219,10 @@ function openFullscreenModal(svgContent: string): void {
const rendered = document.createElement('div')
rendered.className = 'mermaid-rendered'
- rendered.innerHTML = svgContent
+ const modalImg = document.createElement('img')
+ modalImg.src = imgSrc
+ modalImg.alt = 'Mermaid diagram'
+ rendered.appendChild(modalImg)
viewport.appendChild(rendered)
modalContent.appendChild(closeBtn)
@@ -365,14 +368,14 @@ function openFullscreenModal(svgContent: string): void {
// Calculate and apply initial zoom to fit diagram in viewport
requestAnimationFrame(() => {
- const svg = rendered.querySelector('svg')
- if (svg) {
- const svgRect = svg.getBoundingClientRect()
+ const img = rendered.querySelector('img')
+ if (img) {
+ const imgRect = img.getBoundingClientRect()
const viewportRect = viewport.getBoundingClientRect()
initialZoom = calculateFitScale(
- svgRect.width,
- svgRect.height,
+ imgRect.width,
+ imgRect.height,
viewportRect.width,
viewportRect.height
)
@@ -405,7 +408,8 @@ export function initMermaid() {
if (!viewport || !rendered) continue
- const svgContent = rendered.innerHTML
- setupControls(el, viewport, rendered, svgContent)
+ const img = rendered.querySelector('img')
+ const imgSrc = img?.src ?? ''
+ setupControls(el, viewport, rendered, imgSrc)
}
}
diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs
index a3dac18ef..2344aa899 100644
--- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information
using System.Diagnostics.CodeAnalysis;
+using System.Security.Cryptography;
using System.Text;
using Elastic.Documentation.AppliesTo;
using Elastic.Markdown.Diagnostics;
@@ -341,7 +342,7 @@ private static void RenderContributorsHtml(HtmlRenderer renderer, ContributorsBl
"#36b9ff", // blue-sky
];
- /// Renders a Mermaid code block as an inline SVG using Mermaider.
+ /// Renders a Mermaid code block as an external SVG file referenced via an img element.
private static void RenderMermaidBlock(HtmlRenderer renderer, EnhancedCodeBlock block)
{
var mermaidText = ExtractMermaidText(block);
@@ -397,13 +398,48 @@ private static void RenderMermaidBlock(HtmlRenderer renderer, EnhancedCodeBlock
return;
}
+ var svgFileName = WriteSvgFile(block, svg);
+
_ = renderer.Write("
");
_ = renderer.Write("
");
- _ = renderer.Write("
");
- _ = renderer.Write(svg);
+ _ = renderer.Write($"
");
+ _ = renderer.Write($"

");
_ = renderer.Write("
");
}
+ ///
+ /// Writes the SVG to a file next to the page's output HTML and returns the filename.
+ /// The filename is a content hash so identical diagrams on multiple pages share one file.
+ ///
+ private static string WriteSvgFile(EnhancedCodeBlock block, string svg)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(svg)))[..8].ToLowerInvariant();
+ var stem = Path.GetFileNameWithoutExtension(block.CurrentFile.Name);
+ var svgFileName = $"{stem}-{hash}.svg";
+
+ // Mirror HtmlWriter.WriteAsync path logic to find the output directory for this page.
+ var sourceRoot = block.Build.DocumentationSourceDirectory.FullName;
+ var relPath = Path.GetRelativePath(sourceRoot, block.CurrentFile.FullName);
+ var outputSubdir = Path.GetFileName(relPath) == "index.md"
+ ? Path.GetDirectoryName(relPath) ?? "."
+ : Path.Combine(Path.GetDirectoryName(relPath) ?? ".", Path.GetFileNameWithoutExtension(relPath));
+
+ var svgPath = Path.Combine(block.Build.OutputDirectory.FullName, outputSubdir, svgFileName);
+ var svgFile = block.Build.WriteFileSystem.FileInfo.New(svgPath);
+
+ if (!svgFile.Exists)
+ {
+ svgFile.Directory?.Create();
+ block.Build.WriteFileSystem.File.WriteAllText(svgPath, svg);
+ }
+
+ // Use an absolute URL so the src resolves correctly regardless of whether the
+ // browser's current URL has a trailing slash (no-slash: relative would resolve
+ // one level too high and miss the page subdirectory).
+ var urlSubdir = outputSubdir.Replace(Path.DirectorySeparatorChar, '/').Trim('.');
+ return $"{block.Build.UrlPathPrefix}/{urlSubdir}/{svgFileName}".Replace("//", "/");
+ }
+
private static string ExtractMermaidText(EnhancedCodeBlock block)
{
var commonIndent = GetCommonIndent(block);
diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs
index f8922fa23..8ef88049c 100644
--- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs
+++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs
@@ -161,7 +161,7 @@ private void SetUpRoutes()
.UseRouting();
_ = _webApplication.MapGet("/", (ReloadableGeneratorState holder, Cancel ctx) =>
- ServeDocumentationFile(holder, "index", ctx));
+ ServeDocumentationFile(holder, "index", _writeFileSystem, ctx));
_ = _webApplication.MapGet("/api/", (ReloadableGeneratorState holder, Cancel ctx) =>
ServeApiFile(holder, "", ctx));
@@ -212,7 +212,7 @@ private void SetUpRoutes()
Results.Json(buildState.GetCurrentState(), DiagnosticsJsonContext.Default.BuildEvent));
_ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) =>
- ServeDocumentationFile(holder, slug, ctx));
+ ServeDocumentationFile(holder, slug, _writeFileSystem, ctx));
}
private static async Task WriteSSEEvent(HttpResponse response, string eventType, BuildEvent data, Cancel ct)
@@ -255,7 +255,7 @@ private async Task
ServeApiFile(ReloadableGeneratorState holder, string
return Results.NotFound();
}
- private static async Task ServeDocumentationFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
+ private static async Task ServeDocumentationFile(ReloadableGeneratorState holder, string slug, ScopedFileSystem writeFs, Cancel ctx)
{
if (slug == ".well-known/appspecific/com.chrome.devtools.json")
return Results.NotFound();
@@ -310,6 +310,29 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta
if (s == "index.md")
return Results.Redirect(generator.DocumentationSet.Navigation.Url);
+ // Serve static output assets (e.g. Mermaid SVG files written alongside HTML).
+ var ext = Path.GetExtension(slug);
+ if (ext is ".svg" or ".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".webp")
+ {
+ var outputPath = Path.Combine(generator.DocumentationSet.Context.OutputDirectory.FullName, slug);
+ var outputFile = writeFs.FileInfo.New(outputPath);
+ if (outputFile.Exists)
+ {
+ var mimeType = ext switch
+ {
+ ".svg" => "image/svg+xml",
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".gif" => "image/gif",
+ ".ico" => "image/x-icon",
+ ".webp" => "image/webp",
+ _ => "application/octet-stream"
+ };
+ var bytes = await writeFs.File.ReadAllBytesAsync(outputPath, ctx);
+ return Results.Bytes(bytes, mimeType);
+ }
+ }
+
var fp404 = new FilePath("404.md", generator.DocumentationSet.SourceDirectory);
if (!generator.DocumentationSet.Files.TryGetValue(fp404, out var notFoundDocumentationFile))
return Results.NotFound();
diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs
index 631d450c8..28c57c3d8 100644
--- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs
+++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs
@@ -119,6 +119,19 @@ public virtual async ValueTask InitializeAsync()
await Collector.StopAsync(TestContext.Current.CancellationToken);
}
+ ///
+ /// Returns the content of all SVG files written to the output directory during rendering.
+ /// Use this to assert on diagram content after the switch from inline SVG to external files.
+ ///
+ protected IReadOnlyList ReadMermaidSvgs()
+ {
+ var outputDir = Set.Context.OutputDirectory.FullName;
+ return FileSystem.AllFiles
+ .Where(f => f.StartsWith(outputDir, StringComparison.OrdinalIgnoreCase) && f.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
+ .Select(f => FileSystem.File.ReadAllText(f))
+ .ToList();
+ }
+
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
diff --git a/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs b/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs
index 3a8dfc240..f14139014 100644
--- a/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs
+++ b/tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs
@@ -30,14 +30,18 @@ flowchart LR
public void RendersMermaidContainer() => Html.Should().Contain("");
[Fact]
- public void RendersInlineSvg() => Html.Should().Contain("