From eb3c21c54c55f38cbcdb56d05f7dd620852603d1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 23 Jul 2026 13:30:10 +0200 Subject: [PATCH 1/3] Emit Mermaid diagrams as external SVG files loaded via img Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Assets/markdown/mermaid.css | 2 +- .../Assets/mermaid.ts | 26 +++++++------ .../EnhancedCodeBlockHtmlRenderer.cs | 38 +++++++++++++++++-- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css b/src/Elastic.Documentation.Site/Assets/markdown/mermaid.css index 068f647869..8dd77c0106 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 2ec993a2f1..976afc73a5 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 a3dac18efa..05719717c1 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,44 @@ 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($"\"Mermaid"); _ = 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); + } + + return svgFileName; + } + private static string ExtractMermaidText(EnhancedCodeBlock block) { var commonIndent = GetCommonIndent(block); From 16cbf43917611ee18e3d31a23564fef707a4d255 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 23 Jul 2026 13:47:22 +0200 Subject: [PATCH 2/3] Fix mermaid tests and serve SVG assets in dev server Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../docs-builder/Http/DocumentationWebHost.cs | 29 +++++++- .../Directives/DirectiveBaseTests.cs | 13 ++++ .../Directives/MermaidTests.cs | 68 +++++++++++-------- 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index f8922fa230..8ef88049ca 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 631d450c81..28c57c3d8d 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 3a8dfc2403..f14139014c 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(" Html.Should().Contain(" ReadMermaidSvgs().Should().NotBeEmpty(); + + [Fact] + public void SvgContainsNodeLabels() { - Html.Should().Contain("Start"); - Html.Should().Contain("Process"); - Html.Should().Contain("End"); + var svg = ReadMermaidSvgs()[0]; + svg.Should().Contain("Start"); + svg.Should().Contain("Process"); + svg.Should().Contain("End"); } } @@ -62,13 +66,14 @@ participant B as Bob public void RendersMermaidContainer() => Html.Should().Contain("
"); [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Html.Should().Contain("
"); [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Html.Should().Contain("
"); [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Html.Should().Contain("
"); [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Collector.Diagnostics.Should().NotBeEmpty(); [Fact] - public void RendersAsSvg() => Html.Should().Contain(" ReadMermaidSvgs().Should().NotBeEmpty(); [Fact] public void DoesNotFallBackToRawSource() => Html.Should().NotContain("
");
@@ -203,13 +211,13 @@ flowchart LR
 	public void RendersMermaidContainer() => Html.Should().Contain("
"); [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Collector.Diagnostics.Should().BeEmpty(); [Fact] - public void SvgContainsWarningFillColor() => Html.Should().Contain("#fdf3d8"); + public void SvgContainsWarningFillColor() => ReadMermaidSvgs()[0].Should().Contain("#fdf3d8"); } // DataPalette: pie chart SVG should use our theme palette, not the Tableau CB10 default. @@ -225,14 +233,14 @@ public class MermaidPieDataPaletteTests(ITestOutputHelper output) : DirectiveTes ) { [Fact] - public void RendersInlineSvg() => Html.Should().Contain(" Html.Should().Contain(" Collector.Diagnostics.Should().BeEmpty(); [Fact] - public void UsesThemePalette() => Html.Should().Contain("#3788ff"); // blue-elastic-70 + public void UsesThemePalette() => ReadMermaidSvgs()[0].Should().Contain("#3788ff"); // blue-elastic-70 [Fact] - public void DoesNotUseTableauDefault() => Html.Should().NotContain("#4e79a7"); // Tableau Blue + public void DoesNotUseTableauDefault() => ReadMermaidSvgs()[0].Should().NotContain("#4e79a7"); // Tableau Blue } From 458e71a3608bbc32e8d78406bc37829a523796ff Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 23 Jul 2026 13:51:17 +0200 Subject: [PATCH 3/3] Use absolute URL for SVG src to fix trailing-slash resolution Relative src resolves against the wrong directory when the page URL has no trailing slash (e.g. /syntax/diagrams vs /syntax/diagrams/). An absolute URL sidesteps this entirely. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index 05719717c1..2344aa8995 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -433,7 +433,11 @@ private static string WriteSvgFile(EnhancedCodeBlock block, string svg) block.Build.WriteFileSystem.File.WriteAllText(svgPath, svg); } - return svgFileName; + // 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)