Skip to content

Commit e71e3ba

Browse files
committed
Add caching
1 parent 77b60bd commit e71e3ba

File tree

5 files changed

+344
-2
lines changed

5 files changed

+344
-2
lines changed

src/Elastic.Markdown/DocumentationGenerator.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Elastic.Markdown.Helpers;
1717
using Elastic.Markdown.IO;
1818
using Elastic.Markdown.Links.CrossLinks;
19+
using Elastic.Markdown.Myst.Directives.Diagram;
1920
using Elastic.Markdown.Myst.Renderers;
2021
using Elastic.Markdown.Myst.Renderers.LlmMarkdown;
2122
using Markdig.Syntax;
@@ -106,6 +107,9 @@ public async Task ResolveDirectoryTree(Cancel ctx)
106107

107108
public async Task<GenerationResult> GenerateAll(Cancel ctx)
108109
{
110+
// Clear diagram registry for fresh tracking
111+
DiagramRegistry.Clear();
112+
109113
var result = new GenerationResult();
110114

111115
var generationState = Context.SkipDocumentationState ? null : GetPreviousGenerationState();
@@ -142,6 +146,13 @@ public async Task<GenerationResult> GenerateAll(Cancel ctx)
142146
_logger.LogInformation($"Generating links.json");
143147
var linkReference = await GenerateLinkReference(ctx);
144148

149+
// Clean up unused diagram files
150+
var cleanedCount = DiagramRegistry.CleanupUnusedDiagrams(DocumentationSet.OutputDirectory.FullName);
151+
if (cleanedCount > 0)
152+
{
153+
_logger.LogInformation("Cleaned up {CleanedCount} unused diagram files", cleanedCount);
154+
}
155+
145156
// ReSharper disable once WithExpressionModifiesAllMembers
146157
return result with
147158
{

src/Elastic.Markdown/Myst/Directives/Diagram/DiagramBlock.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.Security.Cryptography;
6+
using System.Text;
57
using Elastic.Markdown.Diagnostics;
68

79
namespace Elastic.Markdown.Myst.Directives.Diagram;
@@ -25,6 +27,16 @@ public class DiagramBlock(DirectiveBlockParser parser, ParserContext context) :
2527
/// </summary>
2628
public string? EncodedUrl { get; private set; }
2729

30+
/// <summary>
31+
/// The local SVG path relative to the output directory
32+
/// </summary>
33+
public string? LocalSvgPath { get; private set; }
34+
35+
/// <summary>
36+
/// Content hash for unique identification and caching
37+
/// </summary>
38+
public string? ContentHash { get; private set; }
39+
2840
public override void FinalizeAndValidate(ParserContext context)
2941
{
3042
// Extract diagram type from arguments or default to "mermaid"
@@ -39,6 +51,12 @@ public override void FinalizeAndValidate(ParserContext context)
3951
return;
4052
}
4153

54+
// Generate content hash for caching
55+
ContentHash = GenerateContentHash(DiagramType, Content);
56+
57+
// Generate local path for cached SVG
58+
LocalSvgPath = GenerateLocalPath(context);
59+
4260
// Generate the encoded URL for Kroki
4361
try
4462
{
@@ -47,7 +65,14 @@ public override void FinalizeAndValidate(ParserContext context)
4765
catch (Exception ex)
4866
{
4967
this.EmitError($"Failed to encode diagram: {ex.Message}", ex);
68+
return;
5069
}
70+
71+
// Register diagram for tracking and cleanup
72+
DiagramRegistry.RegisterDiagram(LocalSvgPath);
73+
74+
// Cache diagram asynchronously
75+
_ = Task.Run(() => TryCacheDiagramAsync(context));
5176
}
5277

5378
private string? ExtractContent()
@@ -68,4 +93,60 @@ public override void FinalizeAndValidate(ParserContext context)
6893

6994
return lines.Count > 0 ? string.Join("\n", lines) : null;
7095
}
96+
97+
private string GenerateContentHash(string diagramType, string content)
98+
{
99+
var input = $"{diagramType}:{content}";
100+
var bytes = Encoding.UTF8.GetBytes(input);
101+
var hash = SHA256.HashData(bytes);
102+
return Convert.ToHexString(hash)[..12].ToLowerInvariant();
103+
}
104+
105+
private string GenerateLocalPath(ParserContext context)
106+
{
107+
var markdownFileName = "unknown";
108+
if (context.MarkdownSourcePath?.FullName != null)
109+
{
110+
markdownFileName = Path.GetFileNameWithoutExtension(context.MarkdownSourcePath.FullName);
111+
}
112+
113+
var filename = $"{markdownFileName}-diagram-{DiagramType}-{ContentHash}.svg";
114+
return Path.Combine("images", "generated-graphs", filename);
115+
}
116+
117+
private async Task TryCacheDiagramAsync(ParserContext context)
118+
{
119+
if (string.IsNullOrEmpty(EncodedUrl) || string.IsNullOrEmpty(LocalSvgPath))
120+
return;
121+
122+
try
123+
{
124+
// Determine the full output path
125+
var outputDirectory = context.Build.DocumentationOutputDirectory.FullName;
126+
var fullPath = Path.Combine(outputDirectory, LocalSvgPath);
127+
128+
// Skip if file already exists
129+
if (File.Exists(fullPath))
130+
return;
131+
132+
// Create directory if it doesn't exist
133+
var directory = Path.GetDirectoryName(fullPath);
134+
if (directory != null && !Directory.Exists(directory))
135+
{
136+
_ = Directory.CreateDirectory(directory);
137+
}
138+
139+
// Download SVG from Kroki
140+
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
141+
var svgContent = await httpClient.GetStringAsync(EncodedUrl);
142+
143+
// Write to local file
144+
await File.WriteAllTextAsync(fullPath, svgContent);
145+
}
146+
catch
147+
{
148+
// Silent failure - caching is opportunistic
149+
// The system will fall back to Kroki URLs
150+
}
151+
}
71152
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.IO.Abstractions;
6+
7+
namespace Elastic.Markdown.Myst.Directives.Diagram;
8+
9+
/// <summary>
10+
/// Registry to track active diagrams and manage cleanup of outdated cached files
11+
/// </summary>
12+
public static class DiagramRegistry
13+
{
14+
private static readonly HashSet<string> ActiveDiagrams = [];
15+
private static readonly Lock Lock = new();
16+
17+
/// <summary>
18+
/// Register a diagram as active during the current build
19+
/// </summary>
20+
/// <param name="localSvgPath">The local SVG path relative to output directory</param>
21+
public static void RegisterDiagram(string localSvgPath)
22+
{
23+
if (string.IsNullOrEmpty(localSvgPath))
24+
return;
25+
26+
lock (Lock)
27+
{
28+
_ = ActiveDiagrams.Add(localSvgPath);
29+
}
30+
}
31+
32+
/// <summary>
33+
/// Get all currently registered active diagrams
34+
/// </summary>
35+
/// <returns>Collection of active diagram paths</returns>
36+
public static IReadOnlyCollection<string> GetActiveDiagrams()
37+
{
38+
lock (Lock)
39+
{
40+
return ActiveDiagrams.ToArray();
41+
}
42+
}
43+
44+
/// <summary>
45+
/// Clear all registered diagrams (typically called at start of build)
46+
/// </summary>
47+
public static void Clear()
48+
{
49+
lock (Lock)
50+
{
51+
ActiveDiagrams.Clear();
52+
}
53+
}
54+
55+
/// <summary>
56+
/// Clean up unused diagram files from the output directory
57+
/// </summary>
58+
/// <param name="outputDirectory">The output directory path</param>
59+
/// <returns>Number of files cleaned up</returns>
60+
public static int CleanupUnusedDiagrams(string outputDirectory) =>
61+
CleanupUnusedDiagrams(outputDirectory, new FileSystem());
62+
63+
/// <summary>
64+
/// Clean up unused diagram files from the output directory
65+
/// </summary>
66+
/// <param name="outputDirectory">The output directory path</param>
67+
/// <param name="fileSystem">File system abstraction for testing</param>
68+
/// <returns>Number of files cleaned up</returns>
69+
public static int CleanupUnusedDiagrams(string outputDirectory, IFileSystem fileSystem)
70+
{
71+
if (string.IsNullOrEmpty(outputDirectory))
72+
return 0;
73+
74+
var graphsDir = fileSystem.Path.Combine(outputDirectory, "images", "generated-graphs");
75+
if (!fileSystem.Directory.Exists(graphsDir))
76+
return 0;
77+
78+
var cleanedCount = 0;
79+
var activePaths = GetActiveDiagrams();
80+
81+
try
82+
{
83+
var existingFiles = fileSystem.Directory.GetFiles(graphsDir, "*.svg", SearchOption.AllDirectories);
84+
85+
foreach (var file in existingFiles)
86+
{
87+
var relativePath = fileSystem.Path.GetRelativePath(outputDirectory, file);
88+
89+
// Convert to forward slashes for consistent comparison
90+
var normalizedPath = relativePath.Replace(fileSystem.Path.DirectorySeparatorChar, '/');
91+
92+
if (!activePaths.Any(active => active.Replace(fileSystem.Path.DirectorySeparatorChar, '/') == normalizedPath))
93+
{
94+
try
95+
{
96+
fileSystem.File.Delete(file);
97+
cleanedCount++;
98+
}
99+
catch
100+
{
101+
// Silent failure - cleanup is opportunistic
102+
}
103+
}
104+
}
105+
106+
// Clean up empty directories
107+
CleanupEmptyDirectories(graphsDir, fileSystem);
108+
}
109+
catch
110+
{
111+
// Silent failure - cleanup is opportunistic
112+
}
113+
114+
return cleanedCount;
115+
}
116+
117+
/// <summary>
118+
/// Remove empty directories recursively
119+
/// </summary>
120+
/// <param name="directory">Directory to clean up</param>
121+
/// <param name="fileSystem">File system abstraction</param>
122+
private static void CleanupEmptyDirectories(string directory, IFileSystem fileSystem)
123+
{
124+
try
125+
{
126+
if (!fileSystem.Directory.Exists(directory))
127+
return;
128+
129+
// Clean up subdirectories first
130+
foreach (var subDir in fileSystem.Directory.GetDirectories(directory))
131+
{
132+
CleanupEmptyDirectories(subDir, fileSystem);
133+
}
134+
135+
// Remove directory if it's empty
136+
if (!fileSystem.Directory.EnumerateFileSystemEntries(directory).Any())
137+
{
138+
fileSystem.Directory.Delete(directory);
139+
}
140+
}
141+
catch
142+
{
143+
// Silent failure - cleanup is opportunistic
144+
}
145+
}
146+
}

src/Elastic.Markdown/Myst/Directives/Diagram/DiagramView.cshtml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44
if (diagram?.EncodedUrl != null)
55
{
66
<div class="diagram" data-diagram-type="@diagram.DiagramType">
7-
<img src="@diagram.EncodedUrl" alt="@diagram.DiagramType diagram" loading="lazy" />
7+
@if (!string.IsNullOrEmpty(diagram.LocalSvgPath))
8+
{
9+
<img src="@diagram.LocalSvgPath"
10+
alt="@diagram.DiagramType diagram"
11+
loading="lazy"
12+
onerror="this.src='@diagram.EncodedUrl'" />
13+
}
14+
else
15+
{
16+
<img src="@diagram.EncodedUrl" alt="@diagram.DiagramType diagram" loading="lazy" />
17+
}
818
</div>
919
}
1020
else

0 commit comments

Comments
 (0)