diff --git a/docs/_docset.yml b/docs/_docset.yml index 96aac9673..58475f7ef 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -86,6 +86,7 @@ toc: - file: code.md - file: comments.md - file: conditionals.md + - hidden: diagrams.md - file: dropdowns.md - file: definition-lists.md - file: example_blocks.md diff --git a/docs/syntax/diagrams.md b/docs/syntax/diagrams.md new file mode 100644 index 000000000..ce20375de --- /dev/null +++ b/docs/syntax/diagrams.md @@ -0,0 +1,148 @@ +# Diagrams + +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. + +## Basic usage + +The basic syntax for the diagram directive is: + +```markdown +::::{diagram} [diagram-type] + +:::: +``` + +If no diagram type is specified, it defaults to `mermaid`. + +## Supported diagram types + +The diagram directive supports the following diagram types: + +- `mermaid` - Mermaid diagrams (default) +- `d2` - D2 diagrams +- `graphviz` - Graphviz/DOT diagrams +- `plantuml` - PlantUML diagrams +- `ditaa` - Ditaa diagrams +- `erd` - Entity Relationship diagrams +- `excalidraw` - Excalidraw diagrams +- `nomnoml` - Nomnoml diagrams +- `pikchr` - Pikchr diagrams +- `structurizr` - Structurizr diagrams +- `svgbob` - Svgbob diagrams +- `vega` - Vega diagrams +- `vegalite` - Vega-Lite diagrams +- `wavedrom` - WaveDrom diagrams + +## Examples + +### Mermaid flowchart (default) + +::::::{tab-set} + +:::::{tab-item} Source +```markdown +::::{diagram} +flowchart LR + A[Start] --> B{Decision} + B -->|Yes| C[Action 1] + B -->|No| D[Action 2] + C --> E[End] + D --> E +:::: +``` +::::: + +:::::{tab-item} Rendered +::::{diagram} +flowchart LR + A[Start] --> B{Decision} + B -->|Yes| C[Action 1] + B -->|No| D[Action 2] + C --> E[End] + D --> E +:::: +::::: + +:::::: + +### Mermaid sequence diagram + +::::::{tab-set} + +:::::{tab-item} Source +```markdown +::::{diagram} mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + A->>B: Hello Bob, how are you? + B-->>A: Great! +:::: +``` +::::: + +:::::{tab-item} Rendered +::::{diagram} mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + A->>B: Hello Bob, how are you? + B-->>A: Great! +:::: +::::: + +:::::: + +### D2 diagram + +::::::{tab-set} + +:::::{tab-item} Source +```markdown +::::{diagram} d2 +x -> y: hello world +y -> z: nice to meet you +:::: +``` +::::: + +:::::{tab-item} Rendered +::::{diagram} d2 +x -> y: hello world +y -> z: nice to meet you +:::: +::::: + +:::::: + +### Graphviz diagram + +::::::{tab-set} + +:::::{tab-item} Source +```markdown +::::{diagram} graphviz +digraph G { + rankdir=LR; + A -> B -> C; + A -> C; +} +:::: +``` +::::: + +:::::{tab-item} Rendered +::::{diagram} graphviz +digraph G { + rankdir=LR; + A -> B -> C; + A -> C; +} +:::: +::::: + +:::::: + +## Error handling + +If the diagram content is empty or the encoding fails, an error message will be displayed instead of the diagram. diff --git a/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramBlock.cs b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramBlock.cs new file mode 100644 index 000000000..f9a7c75fb --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramBlock.cs @@ -0,0 +1,71 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives.Diagram; + +public class DiagramBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) +{ + public override string Directive => "diagram"; + + /// + /// The diagram type (e.g., "mermaid", "d2", "graphviz", "plantuml") + /// + public string? DiagramType { get; private set; } + + /// + /// The raw diagram content + /// + public string? Content { get; private set; } + + /// + /// The encoded diagram URL for Kroki service + /// + public string? EncodedUrl { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + // Extract diagram type from arguments or default to "mermaid" + DiagramType = !string.IsNullOrWhiteSpace(Arguments) ? Arguments.ToLowerInvariant() : "mermaid"; + + // Extract content from the directive body + Content = ExtractContent(); + + if (string.IsNullOrWhiteSpace(Content)) + { + this.EmitError("Diagram directive requires content."); + return; + } + + // Generate the encoded URL for Kroki + try + { + EncodedUrl = DiagramEncoder.GenerateKrokiUrl(DiagramType, Content); + } + catch (Exception ex) + { + this.EmitError($"Failed to encode diagram: {ex.Message}", ex); + } + } + + private string? ExtractContent() + { + if (!this.Any()) + return null; + + var lines = new List(); + foreach (var block in this) + { + if (block is Markdig.Syntax.LeafBlock leafBlock) + { + var content = leafBlock.Lines.ToString(); + if (!string.IsNullOrWhiteSpace(content)) + lines.Add(content); + } + } + + return lines.Count > 0 ? string.Join("\n", lines) : null; + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramEncoder.cs b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramEncoder.cs new file mode 100644 index 000000000..94303faf8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramEncoder.cs @@ -0,0 +1,112 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Compression; +using System.Text; + +namespace Elastic.Markdown.Myst.Directives.Diagram; + +/// +/// Utility class for encoding diagrams for use with Kroki service +/// +public static class DiagramEncoder +{ + /// + /// Supported diagram types for Kroki service + /// + private static readonly HashSet SupportedTypes = new(StringComparer.OrdinalIgnoreCase) + { + "mermaid", "d2", "graphviz", "plantuml", "ditaa", "erd", "excalidraw", + "nomnoml", "pikchr", "structurizr", "svgbob", "vega", "vegalite", "wavedrom" + }; + + /// + /// Generates a Kroki URL for the given diagram type and content + /// + /// The type of diagram (e.g., "mermaid", "d2") + /// The diagram content + /// The complete Kroki URL for rendering the diagram as SVG + /// Thrown when diagram type is not supported + /// Thrown when content is null or empty + public static string GenerateKrokiUrl(string diagramType, string content) + { + if (string.IsNullOrWhiteSpace(diagramType)) + throw new ArgumentException("Diagram type cannot be null or empty", nameof(diagramType)); + + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Diagram content cannot be null or empty", nameof(content)); + + var normalizedType = diagramType.ToLowerInvariant(); + if (!SupportedTypes.Contains(normalizedType)) + throw new ArgumentException($"Unsupported diagram type: {diagramType}. Supported types: {string.Join(", ", SupportedTypes)}", nameof(diagramType)); + + var compressedBytes = Deflate(Encoding.UTF8.GetBytes(content)); + var encodedOutput = EncodeBase64Url(compressedBytes); + + return $"https://kroki.io/{normalizedType}/svg/{encodedOutput}"; + } + + /// + /// Compresses data using Deflate compression with zlib headers + /// + /// The data to compress + /// The compression level + /// The compressed data with zlib headers + private static byte[] Deflate(byte[] data, CompressionLevel? level = null) + { + using var memStream = new MemoryStream(); + +#if NET6_0_OR_GREATER + using (var zlibStream = level.HasValue ? new ZLibStream(memStream, level.Value, true) : new ZLibStream(memStream, CompressionMode.Compress, true)) + { + zlibStream.Write(data); + } +#else + // Reference: https://yal.cc/cs-deflatestream-zlib/#code + + // write header: + memStream.WriteByte(0x78); + memStream.WriteByte(level switch + { + CompressionLevel.NoCompression or CompressionLevel.Fastest => 0x01, + CompressionLevel.Optimal => 0x0A, + _ => 0x9C, + }); + + // write compressed data (with Deflate headers): + using (var dflStream = level.HasValue ? new DeflateStream(memStream, level.Value, true) : new DeflateStream(memStream, CompressionMode.Compress, true)) + { + dflStream.Write(data, 0, data.Length); + } + + // compute Adler-32: + uint a1 = 1, a2 = 0; + foreach (byte b in data) + { + a1 = (a1 + b) % 65521; + a2 = (a2 + a1) % 65521; + } + + memStream.WriteByte((byte)(a2 >> 8)); + memStream.WriteByte((byte)a2); + memStream.WriteByte((byte)(a1 >> 8)); + memStream.WriteByte((byte)a1); +#endif + + return memStream.ToArray(); + } + + /// + /// Encodes bytes to Base64URL format + /// + /// The bytes to encode + /// The Base64URL encoded string + private static string EncodeBase64Url(byte[] bytes) => +#if NET9_0_OR_GREATER + // You can use this in previous version of .NET with Microsoft.Bcl.Memory package + System.Buffers.Text.Base64Url.EncodeToString(bytes); +#else + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_'); +#endif +} diff --git a/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramView.cshtml b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramView.cshtml new file mode 100644 index 000000000..40c13b2c3 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramView.cshtml @@ -0,0 +1,16 @@ +@inherits RazorSlice +@{ + var diagram = Model.DiagramBlock; + if (diagram?.EncodedUrl != null) + { +
+ @diagram.DiagramType diagram +
+ } + else + { +
+

Failed to render diagram

+
+ } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramViewModel.cs new file mode 100644 index 000000000..bfee262d7 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Diagram/DiagramViewModel.cs @@ -0,0 +1,13 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Diagram; + +public class DiagramViewModel : DirectiveViewModel +{ + /// + /// The diagram block containing the encoded URL and metadata + /// + public DiagramBlock? DiagramBlock { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 5666cfdc1..a52a17b47 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using Elastic.Markdown.Myst.Directives.Admonition; +using Elastic.Markdown.Myst.Directives.Diagram; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Mermaid; @@ -109,6 +110,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{mermaid}") > 0) return new MermaidBlock(this, context); + if (info.IndexOf("{diagram}") > 0) + return new DiagramBlock(this, context); + if (info.IndexOf("{include}") > 0) return new IncludeBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index fe949f05c..4c8a6e2d6 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -6,6 +6,7 @@ using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Directives.Admonition; +using Elastic.Markdown.Myst.Directives.Diagram; using Elastic.Markdown.Myst.Directives.Dropdown; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; @@ -38,6 +39,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo switch (directiveBlock) { + case DiagramBlock diagramBlock: + WriteDiagram(renderer, diagramBlock); + return; case MermaidBlock mermaidBlock: WriteMermaid(renderer, mermaidBlock); return; @@ -243,6 +247,16 @@ private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteDiagram(HtmlRenderer renderer, DiagramBlock block) + { + var slice = DiagramView.Create(new DiagramViewModel + { + DirectiveBlock = block, + DiagramBlock = block + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteMermaid(HtmlRenderer renderer, MermaidBlock block) { var slice = MermaidView.Create(new MermaidViewModel { DirectiveBlock = block }); diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index b260a1914..8fdebf0e6 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -6,6 +6,7 @@ using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.Directives.Admonition; +using Elastic.Markdown.Myst.Directives.Diagram; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Markdig.Extensions.DefinitionLists; @@ -360,6 +361,9 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case IncludeBlock includeBlock: WriteIncludeBlock(renderer, includeBlock); return; + case DiagramBlock diagramBlock: + WriteDiagramBlock(renderer, diagramBlock); + return; } // Ensure single empty line before directive @@ -405,6 +409,25 @@ private static void WriteImageBlock(LlmMarkdownRenderer renderer, ImageBlock ima renderer.EnsureLine(); } + private static void WriteDiagramBlock(LlmMarkdownRenderer renderer, DiagramBlock diagramBlock) + { + renderer.EnsureBlockSpacing(); + + // Render diagram as structured comment with type information + renderer.WriteLine($""); + + // Render the diagram content with indentation + if (!string.IsNullOrWhiteSpace(diagramBlock.Content)) + { + var reader = new StringReader(diagramBlock.Content); + while (reader.ReadLine() is { } line) + renderer.WriteLine(string.IsNullOrWhiteSpace(line) ? string.Empty : " " + line); + } + + renderer.WriteLine(""); + renderer.EnsureLine(); + } + private void WriteIncludeBlock(LlmMarkdownRenderer renderer, IncludeBlock block) { if (!block.Found || block.IncludePath is null) diff --git a/tests/Elastic.Markdown.Tests/Directives/DiagramTests.cs b/tests/Elastic.Markdown.Tests/Directives/DiagramTests.cs new file mode 100644 index 000000000..b55d5eeec --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/DiagramTests.cs @@ -0,0 +1,84 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Myst.Directives.Diagram; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Directives; + +public class DiagramBlockTests(ITestOutputHelper output) : DirectiveTest(output, +""" +::::{diagram} mermaid +flowchart LR + A[Start] --> B[Process] + B --> C[End] +:::: +""" +) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void ExtractsDiagramType() => Block!.DiagramType.Should().Be("mermaid"); + + [Fact] + public void ExtractsContent() => Block!.Content.Should().Contain("flowchart LR"); + + [Fact] + public void GeneratesEncodedUrl() => Block!.EncodedUrl.Should().StartWith("https://kroki.io/mermaid/svg/"); + + [Fact] + public void RendersImageTag() => Html.Should().Contain("(output, +""" +::::{diagram} d2 +x -> y +y -> z +:::: +""" +) +{ + [Fact] + public void ParsesD2Block() => Block.Should().NotBeNull(); + + [Fact] + public void ExtractsD2Type() => Block!.DiagramType.Should().Be("d2"); + + [Fact] + public void ExtractsD2Content() => Block!.Content.Should().Contain("x -> y"); + + [Fact] + public void GeneratesD2EncodedUrl() => Block!.EncodedUrl.Should().StartWith("https://kroki.io/d2/svg/"); +} + +public class DiagramBlockDefaultTests(ITestOutputHelper output) : DirectiveTest(output, +""" +::::{diagram} +graph TD + A --> B +:::: +""" +) +{ + [Fact] + public void DefaultsToMermaid() => Block!.DiagramType.Should().Be("mermaid"); + + [Fact] + public void ExtractsContentWithoutType() => Block!.Content.Should().Contain("graph TD"); +} + +public class DiagramBlockEmptyTests(ITestOutputHelper output) : DirectiveTest(output, +""" +::::{diagram} +:::: +""" +) +{ + [Fact] + public void EmptyContentGeneratesError() => + Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("Diagram directive requires content")); +} diff --git a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs index 97d1f4f31..7daec9c86 100644 --- a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs +++ b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs @@ -488,6 +488,41 @@ Hello, this is a substitution: {{hello-world}} Hello, this is a substitution: Hello World! ``` """ + +type ``diagram directive`` () = + static let markdown = Setup.Document """ +::::{diagram} mermaid +flowchart LR + A[Start] --> B{Decision} + B -->|Yes| C[Action 1] + B -->|No| D[Action 2] + C --> E[End] + D --> E +:::: + +::::{diagram} d2 +x -> y: hello world +y -> z: nice to meet you +:::: +""" + + [] + let ``renders diagram with type information`` () = + markdown |> convertsToNewLLM """ + + flowchart LR + A[Start] --> B{Decision} + B -->|Yes| C[Action 1] + B -->|No| D[Action 2] + C --> E[End] + D --> E + + + + x -> y: hello world + y -> z: nice to meet you + +""" type ``substitution in heading`` () = static let markdown = Setup.Document """---