Skip to content

Commit 77b60bd

Browse files
authored
Add diagrams support using Kroki (#1599)
* First commit * Update docs * Update docs * Fix lint issue * Delete file * Add LLM generation logic and tests * Review edit * Make diagrams doc hidden
1 parent 3ecf00e commit 77b60bd

File tree

11 files changed

+521
-0
lines changed

11 files changed

+521
-0
lines changed

docs/_docset.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ toc:
8686
- file: code.md
8787
- file: comments.md
8888
- file: conditionals.md
89+
- hidden: diagrams.md
8990
- file: dropdowns.md
9091
- file: definition-lists.md
9192
- file: example_blocks.md

docs/syntax/diagrams.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Diagrams
2+
3+
The `diagram` directive allows you to render various types of diagrams using the [Kroki](https://kroki.io/) service. Kroki supports many diagram types including Mermaid, D2, Graphviz, PlantUML, and more.
4+
5+
## Basic usage
6+
7+
The basic syntax for the diagram directive is:
8+
9+
```markdown
10+
::::{diagram} [diagram-type]
11+
<diagram content>
12+
::::
13+
```
14+
15+
If no diagram type is specified, it defaults to `mermaid`.
16+
17+
## Supported diagram types
18+
19+
The diagram directive supports the following diagram types:
20+
21+
- `mermaid` - Mermaid diagrams (default)
22+
- `d2` - D2 diagrams
23+
- `graphviz` - Graphviz/DOT diagrams
24+
- `plantuml` - PlantUML diagrams
25+
- `ditaa` - Ditaa diagrams
26+
- `erd` - Entity Relationship diagrams
27+
- `excalidraw` - Excalidraw diagrams
28+
- `nomnoml` - Nomnoml diagrams
29+
- `pikchr` - Pikchr diagrams
30+
- `structurizr` - Structurizr diagrams
31+
- `svgbob` - Svgbob diagrams
32+
- `vega` - Vega diagrams
33+
- `vegalite` - Vega-Lite diagrams
34+
- `wavedrom` - WaveDrom diagrams
35+
36+
## Examples
37+
38+
### Mermaid flowchart (default)
39+
40+
::::::{tab-set}
41+
42+
:::::{tab-item} Source
43+
```markdown
44+
::::{diagram}
45+
flowchart LR
46+
A[Start] --> B{Decision}
47+
B -->|Yes| C[Action 1]
48+
B -->|No| D[Action 2]
49+
C --> E[End]
50+
D --> E
51+
::::
52+
```
53+
:::::
54+
55+
:::::{tab-item} Rendered
56+
::::{diagram}
57+
flowchart LR
58+
A[Start] --> B{Decision}
59+
B -->|Yes| C[Action 1]
60+
B -->|No| D[Action 2]
61+
C --> E[End]
62+
D --> E
63+
::::
64+
:::::
65+
66+
::::::
67+
68+
### Mermaid sequence diagram
69+
70+
::::::{tab-set}
71+
72+
:::::{tab-item} Source
73+
```markdown
74+
::::{diagram} mermaid
75+
sequenceDiagram
76+
participant A as Alice
77+
participant B as Bob
78+
A->>B: Hello Bob, how are you?
79+
B-->>A: Great!
80+
::::
81+
```
82+
:::::
83+
84+
:::::{tab-item} Rendered
85+
::::{diagram} mermaid
86+
sequenceDiagram
87+
participant A as Alice
88+
participant B as Bob
89+
A->>B: Hello Bob, how are you?
90+
B-->>A: Great!
91+
::::
92+
:::::
93+
94+
::::::
95+
96+
### D2 diagram
97+
98+
::::::{tab-set}
99+
100+
:::::{tab-item} Source
101+
```markdown
102+
::::{diagram} d2
103+
x -> y: hello world
104+
y -> z: nice to meet you
105+
::::
106+
```
107+
:::::
108+
109+
:::::{tab-item} Rendered
110+
::::{diagram} d2
111+
x -> y: hello world
112+
y -> z: nice to meet you
113+
::::
114+
:::::
115+
116+
::::::
117+
118+
### Graphviz diagram
119+
120+
::::::{tab-set}
121+
122+
:::::{tab-item} Source
123+
```markdown
124+
::::{diagram} graphviz
125+
digraph G {
126+
rankdir=LR;
127+
A -> B -> C;
128+
A -> C;
129+
}
130+
::::
131+
```
132+
:::::
133+
134+
:::::{tab-item} Rendered
135+
::::{diagram} graphviz
136+
digraph G {
137+
rankdir=LR;
138+
A -> B -> C;
139+
A -> C;
140+
}
141+
::::
142+
:::::
143+
144+
::::::
145+
146+
## Error handling
147+
148+
If the diagram content is empty or the encoding fails, an error message will be displayed instead of the diagram.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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 Elastic.Markdown.Diagnostics;
6+
7+
namespace Elastic.Markdown.Myst.Directives.Diagram;
8+
9+
public class DiagramBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context)
10+
{
11+
public override string Directive => "diagram";
12+
13+
/// <summary>
14+
/// The diagram type (e.g., "mermaid", "d2", "graphviz", "plantuml")
15+
/// </summary>
16+
public string? DiagramType { get; private set; }
17+
18+
/// <summary>
19+
/// The raw diagram content
20+
/// </summary>
21+
public string? Content { get; private set; }
22+
23+
/// <summary>
24+
/// The encoded diagram URL for Kroki service
25+
/// </summary>
26+
public string? EncodedUrl { get; private set; }
27+
28+
public override void FinalizeAndValidate(ParserContext context)
29+
{
30+
// Extract diagram type from arguments or default to "mermaid"
31+
DiagramType = !string.IsNullOrWhiteSpace(Arguments) ? Arguments.ToLowerInvariant() : "mermaid";
32+
33+
// Extract content from the directive body
34+
Content = ExtractContent();
35+
36+
if (string.IsNullOrWhiteSpace(Content))
37+
{
38+
this.EmitError("Diagram directive requires content.");
39+
return;
40+
}
41+
42+
// Generate the encoded URL for Kroki
43+
try
44+
{
45+
EncodedUrl = DiagramEncoder.GenerateKrokiUrl(DiagramType, Content);
46+
}
47+
catch (Exception ex)
48+
{
49+
this.EmitError($"Failed to encode diagram: {ex.Message}", ex);
50+
}
51+
}
52+
53+
private string? ExtractContent()
54+
{
55+
if (!this.Any())
56+
return null;
57+
58+
var lines = new List<string>();
59+
foreach (var block in this)
60+
{
61+
if (block is Markdig.Syntax.LeafBlock leafBlock)
62+
{
63+
var content = leafBlock.Lines.ToString();
64+
if (!string.IsNullOrWhiteSpace(content))
65+
lines.Add(content);
66+
}
67+
}
68+
69+
return lines.Count > 0 ? string.Join("\n", lines) : null;
70+
}
71+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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.Compression;
6+
using System.Text;
7+
8+
namespace Elastic.Markdown.Myst.Directives.Diagram;
9+
10+
/// <summary>
11+
/// Utility class for encoding diagrams for use with Kroki service
12+
/// </summary>
13+
public static class DiagramEncoder
14+
{
15+
/// <summary>
16+
/// Supported diagram types for Kroki service
17+
/// </summary>
18+
private static readonly HashSet<string> SupportedTypes = new(StringComparer.OrdinalIgnoreCase)
19+
{
20+
"mermaid", "d2", "graphviz", "plantuml", "ditaa", "erd", "excalidraw",
21+
"nomnoml", "pikchr", "structurizr", "svgbob", "vega", "vegalite", "wavedrom"
22+
};
23+
24+
/// <summary>
25+
/// Generates a Kroki URL for the given diagram type and content
26+
/// </summary>
27+
/// <param name="diagramType">The type of diagram (e.g., "mermaid", "d2")</param>
28+
/// <param name="content">The diagram content</param>
29+
/// <returns>The complete Kroki URL for rendering the diagram as SVG</returns>
30+
/// <exception cref="ArgumentException">Thrown when diagram type is not supported</exception>
31+
/// <exception cref="ArgumentNullException">Thrown when content is null or empty</exception>
32+
public static string GenerateKrokiUrl(string diagramType, string content)
33+
{
34+
if (string.IsNullOrWhiteSpace(diagramType))
35+
throw new ArgumentException("Diagram type cannot be null or empty", nameof(diagramType));
36+
37+
if (string.IsNullOrWhiteSpace(content))
38+
throw new ArgumentException("Diagram content cannot be null or empty", nameof(content));
39+
40+
var normalizedType = diagramType.ToLowerInvariant();
41+
if (!SupportedTypes.Contains(normalizedType))
42+
throw new ArgumentException($"Unsupported diagram type: {diagramType}. Supported types: {string.Join(", ", SupportedTypes)}", nameof(diagramType));
43+
44+
var compressedBytes = Deflate(Encoding.UTF8.GetBytes(content));
45+
var encodedOutput = EncodeBase64Url(compressedBytes);
46+
47+
return $"https://kroki.io/{normalizedType}/svg/{encodedOutput}";
48+
}
49+
50+
/// <summary>
51+
/// Compresses data using Deflate compression with zlib headers
52+
/// </summary>
53+
/// <param name="data">The data to compress</param>
54+
/// <param name="level">The compression level</param>
55+
/// <returns>The compressed data with zlib headers</returns>
56+
private static byte[] Deflate(byte[] data, CompressionLevel? level = null)
57+
{
58+
using var memStream = new MemoryStream();
59+
60+
#if NET6_0_OR_GREATER
61+
using (var zlibStream = level.HasValue ? new ZLibStream(memStream, level.Value, true) : new ZLibStream(memStream, CompressionMode.Compress, true))
62+
{
63+
zlibStream.Write(data);
64+
}
65+
#else
66+
// Reference: https://yal.cc/cs-deflatestream-zlib/#code
67+
68+
// write header:
69+
memStream.WriteByte(0x78);
70+
memStream.WriteByte(level switch
71+
{
72+
CompressionLevel.NoCompression or CompressionLevel.Fastest => 0x01,
73+
CompressionLevel.Optimal => 0x0A,
74+
_ => 0x9C,
75+
});
76+
77+
// write compressed data (with Deflate headers):
78+
using (var dflStream = level.HasValue ? new DeflateStream(memStream, level.Value, true) : new DeflateStream(memStream, CompressionMode.Compress, true))
79+
{
80+
dflStream.Write(data, 0, data.Length);
81+
}
82+
83+
// compute Adler-32:
84+
uint a1 = 1, a2 = 0;
85+
foreach (byte b in data)
86+
{
87+
a1 = (a1 + b) % 65521;
88+
a2 = (a2 + a1) % 65521;
89+
}
90+
91+
memStream.WriteByte((byte)(a2 >> 8));
92+
memStream.WriteByte((byte)a2);
93+
memStream.WriteByte((byte)(a1 >> 8));
94+
memStream.WriteByte((byte)a1);
95+
#endif
96+
97+
return memStream.ToArray();
98+
}
99+
100+
/// <summary>
101+
/// Encodes bytes to Base64URL format
102+
/// </summary>
103+
/// <param name="bytes">The bytes to encode</param>
104+
/// <returns>The Base64URL encoded string</returns>
105+
private static string EncodeBase64Url(byte[] bytes) =>
106+
#if NET9_0_OR_GREATER
107+
// You can use this in previous version of .NET with Microsoft.Bcl.Memory package
108+
System.Buffers.Text.Base64Url.EncodeToString(bytes);
109+
#else
110+
Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_');
111+
#endif
112+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@inherits RazorSlice<Elastic.Markdown.Myst.Directives.Diagram.DiagramViewModel>
2+
@{
3+
var diagram = Model.DiagramBlock;
4+
if (diagram?.EncodedUrl != null)
5+
{
6+
<div class="diagram" data-diagram-type="@diagram.DiagramType">
7+
<img src="@diagram.EncodedUrl" alt="@diagram.DiagramType diagram" loading="lazy" />
8+
</div>
9+
}
10+
else
11+
{
12+
<div class="diagram-error">
13+
<p>Failed to render diagram</p>
14+
</div>
15+
}
16+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
namespace Elastic.Markdown.Myst.Directives.Diagram;
6+
7+
public class DiagramViewModel : DirectiveViewModel
8+
{
9+
/// <summary>
10+
/// The diagram block containing the encoded URL and metadata
11+
/// </summary>
12+
public DiagramBlock? DiagramBlock { get; set; }
13+
}

src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using System.Collections.Frozen;
66
using Elastic.Markdown.Myst.Directives.Admonition;
7+
using Elastic.Markdown.Myst.Directives.Diagram;
78
using Elastic.Markdown.Myst.Directives.Image;
89
using Elastic.Markdown.Myst.Directives.Include;
910
using Elastic.Markdown.Myst.Directives.Mermaid;
@@ -109,6 +110,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor)
109110
if (info.IndexOf("{mermaid}") > 0)
110111
return new MermaidBlock(this, context);
111112

113+
if (info.IndexOf("{diagram}") > 0)
114+
return new DiagramBlock(this, context);
115+
112116
if (info.IndexOf("{include}") > 0)
113117
return new IncludeBlock(this, context);
114118

0 commit comments

Comments
 (0)