Forge
csharpdeeb25a2
1using System.Collections.Concurrent;
2using System.Net.Http.Json;
3using System.Security.Cryptography;
4using System.Text;
5using System.Text.Json.Serialization;
6using CascadeIDE.Models;
7
8namespace CascadeIDE.Services;
9
10/// <summary>
11/// Заменяет в Markdown fenced-блоки <c>```mermaid</c> / <c>```plantuml</c> на встроенные SVG через <a href="https://kroki.io">Kroki</a>.
12/// </summary>
13public static class MarkdownDiagramExpansion
14{
15 private static readonly HttpClient Http = new()
16 {
17 Timeout = TimeSpan.FromMinutes(2)
18 };
19
20 private static readonly SemaphoreSlim KrokiParallel = new(4, 4);
21
22 private static readonly ConcurrentDictionary<string, byte[]> SvgCache = new(StringComparer.Ordinal);
23
24 private static readonly System.Text.RegularExpressions.Regex DiagramFenceRegex = new(
25 @"```\s*(mermaid|plantuml|puml)\s*\r?\n([\s\S]*?)```",
26 System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled,
27 TimeSpan.FromSeconds(5));
28
29 /// <summary>Расширить markdown: блоки mermaid/plantuml → <c>![…](data:image/svg+xml;base64,…)</c>.</summary>
30 public static async Task<string> ExpandAsync(string markdown, CascadeIdeSettings settings, CancellationToken cancellationToken = default)
31 {
32 if (!settings.Markdown.Diagrams.Kroki || string.IsNullOrEmpty(markdown))
33 return markdown;
34
35 var baseUrl = string.IsNullOrWhiteSpace(settings.Markdown.Diagrams.KrokiUrl)
36 ? "https://kroki.io"
37 : settings.Markdown.Diagrams.KrokiUrl.Trim();
38
39 var matches = DiagramFenceRegex.Matches(markdown);
40 if (matches.Count == 0)
41 return markdown;
42
43 var tasks = new List<Task<BlockReplace>>(matches.Count);
44 foreach (System.Text.RegularExpressions.Match m in matches)
45 {
46 if (!m.Success)
47 continue;
48 var lang = m.Groups[1].Value.ToLowerInvariant();
49 var body = m.Groups[2].Value;
50 var diagramType = lang == "puml" ? "plantuml" : lang;
51 tasks.Add(ReplaceOneAsync(baseUrl, diagramType, body, m.Index, m.Length, m.Value, cancellationToken));
52 }
53
54 if (tasks.Count == 0)
55 return markdown;
56
57 var blocks = await Task.WhenAll(tasks).ConfigureAwait(false);
58 Array.Sort(blocks, (a, b) => a.Index.CompareTo(b.Index));
59
60 var sb = new StringBuilder(markdown.Length + blocks.Length * 200);
61 var copyFrom = 0;
62 foreach (var b in blocks)
63 {
64 sb.Append(markdown, copyFrom, b.Index - copyFrom);
65 sb.Append(b.Replacement);
66 copyFrom = b.Index + b.Length;
67 }
68
69 sb.Append(markdown, copyFrom, markdown.Length - copyFrom);
70 return sb.ToString();
71 }
72
73 private sealed record BlockReplace(int Index, int Length, string Replacement);
74
75 private static async Task<BlockReplace> ReplaceOneAsync(
76 string krokiBaseUrl,
77 string diagramType,
78 string body,
79 int index,
80 int length,
81 string originalFence,
82 CancellationToken cancellationToken)
83 {
84 var trimmed = body.TrimEnd();
85 if (trimmed.Length == 0)
86 return new BlockReplace(index, length, originalFence);
87
88 try
89 {
90 await KrokiParallel.WaitAsync(cancellationToken).ConfigureAwait(false);
91 try
92 {
93 var cacheKey = CacheKey(krokiBaseUrl, diagramType, trimmed);
94 if (!SvgCache.TryGetValue(cacheKey, out var svgBytes))
95 {
96 svgBytes = await FetchSvgFromKrokiAsync(krokiBaseUrl, diagramType, trimmed, cancellationToken).ConfigureAwait(false);
97 SvgCache[cacheKey] = svgBytes;
98 }
99
100 var b64 = Convert.ToBase64String(svgBytes);
101 var label = diagramType == "mermaid" ? "Mermaid" : "PlantUML";
102 var md = $"\n\n![{label}](data:image/svg+xml;base64,{b64})\n\n";
103 return new BlockReplace(index, length, md);
104 }
105 finally
106 {
107 KrokiParallel.Release();
108 }
109 }
110 catch (OperationCanceledException)
111 {
112 throw;
113 }
114 catch
115 {
116 return new BlockReplace(
117 index,
118 length,
119 "\n\n> **Диаграмма:** не удалось отрисовать через Kroki. Показан исходник.\n\n" + originalFence + "\n\n");
120 }
121 }
122
123 private static string CacheKey(string baseUrl, string diagramType, string body)
124 {
125 var payload = $"{baseUrl}\n{diagramType}\n{body}";
126 var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
127 return Convert.ToHexString(hash);
128 }
129
130 private static async Task<byte[]> FetchSvgFromKrokiAsync(string krokiBaseUrl, string diagramType, string source, CancellationToken cancellationToken)
131 {
132 var url = krokiBaseUrl.TrimEnd('/') + "/";
133 var payload = new KrokiJsonBody
134 {
135 DiagramSource = source,
136 DiagramType = diagramType,
137 OutputFormat = "svg"
138 };
139
140 using var req = new HttpRequestMessage(HttpMethod.Post, url);
141 req.Content = JsonContent.Create(payload);
142 req.Headers.Accept.ParseAdd("image/svg+xml");
143
144 var resp = await Http.SendAsync(req, cancellationToken).ConfigureAwait(false);
145 resp.EnsureSuccessStatusCode();
146 return await resp.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
147 }
148
149 private sealed class KrokiJsonBody
150 {
151 [JsonPropertyName("diagram_source")]
152 public string DiagramSource { get; set; } = "";
153
154 [JsonPropertyName("diagram_type")]
155 public string DiagramType { get; set; } = "";
156
157 [JsonPropertyName("output_format")]
158 public string OutputFormat { get; set; } = "svg";
159 }
160}
161
View only · write via MCP/CIDE