Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
transition: transform 0.1s ease-out;
}

.mermaid-rendered svg {
.mermaid-rendered img {
height: auto;
max-width: 100%;
}
Expand Down
26 changes: 15 additions & 11 deletions src/Elastic.Documentation.Site/Assets/mermaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function setupControls(
container: HTMLElement,
viewport: HTMLElement,
rendered: HTMLElement,
svgContent: string
imgSrc: string
): void {
const state: DiagramState = {
zoom: 1,
Expand Down Expand Up @@ -131,7 +131,7 @@ function setupControls(

// Fullscreen handler
fullscreenBtn.addEventListener('click', () => {
openFullscreenModal(svgContent)
openFullscreenModal(imgSrc)
})

// Pan with mouse drag
Expand Down Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -341,7 +342,7 @@ private static void RenderContributorsHtml(HtmlRenderer renderer, ContributorsBl
"#36b9ff", // blue-sky
];

/// <summary>Renders a Mermaid code block as an inline SVG using Mermaider.</summary>
/// <summary>Renders a Mermaid code block as an external SVG file referenced via an img element.</summary>
private static void RenderMermaidBlock(HtmlRenderer renderer, EnhancedCodeBlock block)
{
var mermaidText = ExtractMermaidText(block);
Expand Down Expand Up @@ -397,13 +398,48 @@ private static void RenderMermaidBlock(HtmlRenderer renderer, EnhancedCodeBlock
return;
}

var svgFileName = WriteSvgFile(block, svg);

_ = renderer.Write("<div class=\"mermaid-container\">");
_ = renderer.Write("<div class=\"mermaid-viewport\">");
_ = renderer.Write("<div class=\"mermaid-rendered\">");
_ = renderer.Write(svg);
_ = renderer.Write($"<div class=\"mermaid-rendered\" data-src=\"{svgFileName}\">");
_ = renderer.Write($"<img src=\"{svgFileName}\" alt=\"Mermaid diagram\">");
_ = renderer.Write("</div></div></div>");
}

/// <summary>
/// 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.
/// </summary>
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);
Expand Down
29 changes: 26 additions & 3 deletions src/tooling/docs-builder/Http/DocumentationWebHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -255,7 +255,7 @@ private async Task<IResult> ServeApiFile(ReloadableGeneratorState holder, string
return Results.NotFound();
}

private static async Task<IResult> ServeDocumentationFile(ReloadableGeneratorState holder, string slug, Cancel ctx)
private static async Task<IResult> ServeDocumentationFile(ReloadableGeneratorState holder, string slug, ScopedFileSystem writeFs, Cancel ctx)
{
if (slug == ".well-known/appspecific/com.chrome.devtools.json")
return Results.NotFound();
Expand Down Expand Up @@ -310,6 +310,29 @@ private static async Task<IResult> 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();
Expand Down
13 changes: 13 additions & 0 deletions tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ public virtual async ValueTask InitializeAsync()
await Collector.StopAsync(TestContext.Current.CancellationToken);
}

/// <summary>
/// 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.
/// </summary>
protected IReadOnlyList<string> 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);
Expand Down
68 changes: 38 additions & 30 deletions tests/Elastic.Markdown.Tests/Directives/MermaidTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ flowchart LR
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void ContainsNodeLabels()
public void EmitsExternalSvgFile() => 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");
}
}

Expand All @@ -62,13 +66,14 @@ participant B as Bob
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void ContainsParticipantLabels()
public void SvgContainsParticipantLabels()
{
Html.Should().Contain("Alice");
Html.Should().Contain("Bob");
var svg = ReadMermaidSvgs()[0];
svg.Should().Contain("Alice");
svg.Should().Contain("Bob");
}
}

Expand All @@ -93,14 +98,15 @@ public class MermaidStateDiagramTests(ITestOutputHelper output) : DirectiveTest(
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void ContainsStateLabels()
public void SvgContainsStateLabels()
{
Html.Should().Contain("Idle");
Html.Should().Contain("Processing");
Html.Should().Contain("Complete");
var svg = ReadMermaidSvgs()[0];
svg.Should().Contain("Idle");
svg.Should().Contain("Processing");
svg.Should().Contain("Complete");
}
}

Expand All @@ -124,14 +130,15 @@ public class MermaidClassDiagramTests(ITestOutputHelper output) : DirectiveTest(
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void ContainsClassLabels()
public void SvgContainsClassLabels()
{
Html.Should().Contain("Animal");
Html.Should().Contain("Duck");
Html.Should().Contain("Fish");
var svg = ReadMermaidSvgs()[0];
svg.Should().Contain("Animal");
svg.Should().Contain("Duck");
svg.Should().Contain("Fish");
}
}

Expand All @@ -154,14 +161,15 @@ public class MermaidErDiagramTests(ITestOutputHelper output) : DirectiveTest(out
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void ContainsEntityLabels()
public void SvgContainsEntityLabels()
{
Html.Should().Contain("CUSTOMER");
Html.Should().Contain("ORDER");
Html.Should().Contain("LINE_ITEM");
var svg = ReadMermaidSvgs()[0];
svg.Should().Contain("CUSTOMER");
svg.Should().Contain("ORDER");
svg.Should().Contain("LINE_ITEM");
}
}

Expand All @@ -183,7 +191,7 @@ class A elasticBlue
public void EmitsHints() => Collector.Diagnostics.Should().NotBeEmpty();

[Fact]
public void RendersAsSvg() => Html.Should().Contain("<svg");
public void EmitsSvgFile() => ReadMermaidSvgs().Should().NotBeEmpty();

[Fact]
public void DoesNotFallBackToRawSource() => Html.Should().NotContain("<pre class=\"mermaid-error\">");
Expand All @@ -203,13 +211,13 @@ flowchart LR
public void RendersMermaidContainer() => Html.Should().Contain("<div class=\"mermaid-container\">");

[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void EmitsNoDiagnostics() => 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.
Expand All @@ -225,14 +233,14 @@ public class MermaidPieDataPaletteTests(ITestOutputHelper output) : DirectiveTes
)
{
[Fact]
public void RendersInlineSvg() => Html.Should().Contain("<svg");
public void RendersImgElement() => Html.Should().Contain("<img");

[Fact]
public void EmitsNoDiagnostics() => 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
}
Loading