Skip to content

Commit 9233418

Browse files
authored
Merge pull request #106 from jongalloway/feature/icon-pack-support
feat: pluggable SVG icon pack support for architecture diagrams
2 parents 9fc6d53 + b9ff2d9 commit 9233418

24 files changed

Lines changed: 1225 additions & 41 deletions

DiagramForge.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
</Folder>
55
<Folder Name="/src/">
66
<Project Path="src/DiagramForge.Cli/DiagramForge.Cli.csproj" />
7+
<Project Path="src/DiagramForge.Icons.Heroicons/DiagramForge.Icons.Heroicons.csproj" />
78
<Project Path="src/DiagramForge/DiagramForge.csproj" />
89
</Folder>
910
<Folder Name="/tests/">
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<ItemGroup>
4+
<ProjectReference Include="..\DiagramForge\DiagramForge.csproj" />
5+
</ItemGroup>
6+
7+
<ItemGroup>
8+
<None Include="..\..\LICENSE" Pack="true" PackagePath="" Link="LICENSE" />
9+
</ItemGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="MinVer" PrivateAssets="All" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<EmbeddedResource Include="IconData\heroicons.json" LogicalName="heroicons.json" />
17+
</ItemGroup>
18+
19+
<PropertyGroup>
20+
<TargetFramework>net10.0</TargetFramework>
21+
<ImplicitUsings>enable</ImplicitUsings>
22+
<Nullable>enable</Nullable>
23+
<IsPackable>true</IsPackable>
24+
25+
<PackageId>DiagramForge.Icons.Heroicons</PackageId>
26+
<Title>DiagramForge Heroicons Icon Pack</Title>
27+
<Description>Heroicons SVG icon pack for DiagramForge. MIT-licensed outline icons from the Heroicons project.</Description>
28+
<Authors>Jon Galloway</Authors>
29+
<PackageTags>diagram svg icons heroicons diagramforge</PackageTags>
30+
<PackageLicenseFile>LICENSE</PackageLicenseFile>
31+
<RepositoryUrl>https://github.com/jongalloway/DiagramForge</RepositoryUrl>
32+
<RepositoryType>git</RepositoryType>
33+
<PackageProjectUrl>https://github.com/jongalloway/DiagramForge</PackageProjectUrl>
34+
</PropertyGroup>
35+
36+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace DiagramForge.Icons.Heroicons;
2+
3+
/// <summary>
4+
/// Extension methods for registering the Heroicons icon pack with DiagramForge.
5+
/// </summary>
6+
public static class HeroiconsExtensions
7+
{
8+
/// <summary>
9+
/// Registers the Heroicons (outline, 24×24) icon pack with the renderer
10+
/// under the pack name <c>"heroicons"</c>.
11+
/// </summary>
12+
/// <returns>The renderer, for fluent chaining.</returns>
13+
public static DiagramRenderer UseHeroicons(this DiagramRenderer renderer)
14+
{
15+
ArgumentNullException.ThrowIfNull(renderer);
16+
renderer.RegisterIconPack("heroicons", new HeroiconsProvider());
17+
return renderer;
18+
}
19+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Text.Json;
2+
using DiagramForge.Abstractions;
3+
using DiagramForge.Models;
4+
5+
namespace DiagramForge.Icons.Heroicons;
6+
7+
/// <summary>
8+
/// Provides Heroicons (outline, 24×24) as a DiagramForge icon pack.
9+
/// Icons are loaded from an embedded JSON resource on first access.
10+
/// </summary>
11+
public sealed class HeroiconsProvider : IIconProvider
12+
{
13+
private const string ViewBox = "0 0 24 24";
14+
private readonly Lazy<Dictionary<string, string>> _icons = new(LoadIcons);
15+
16+
/// <inheritdoc/>
17+
public DiagramIcon? GetIcon(string name)
18+
{
19+
if (_icons.Value.TryGetValue(name, out var svgContent))
20+
return new DiagramIcon("heroicons", name, ViewBox, svgContent);
21+
22+
return null;
23+
}
24+
25+
/// <inheritdoc/>
26+
public IEnumerable<string> AvailableIcons => _icons.Value.Keys;
27+
28+
private static Dictionary<string, string> LoadIcons()
29+
{
30+
var assembly = typeof(HeroiconsProvider).Assembly;
31+
using var stream = assembly.GetManifestResourceStream("heroicons.json")
32+
?? throw new InvalidOperationException("Heroicons embedded resource not found.");
33+
34+
var data = JsonSerializer.Deserialize(stream, HeroiconsJsonContext.Default.DictionaryStringString)
35+
?? throw new InvalidOperationException("Failed to deserialize heroicons data.");
36+
37+
return new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
38+
}
39+
}
40+
41+
[System.Text.Json.Serialization.JsonSerializable(typeof(Dictionary<string, string>))]
42+
internal partial class HeroiconsJsonContext : System.Text.Json.Serialization.JsonSerializerContext
43+
{
44+
}

src/DiagramForge.Icons.Heroicons/IconData/heroicons.json

Lines changed: 46 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using DiagramForge.Models;
2+
3+
namespace DiagramForge.Abstractions;
4+
5+
/// <summary>
6+
/// Provides icons by name within a single icon pack.
7+
/// </summary>
8+
public interface IIconProvider
9+
{
10+
/// <summary>
11+
/// Resolves an icon by <paramref name="name"/> within this provider's pack.
12+
/// Returns <see langword="null"/> if the icon is not found.
13+
/// </summary>
14+
DiagramIcon? GetIcon(string name);
15+
16+
/// <summary>
17+
/// Returns all icon names available in this provider.
18+
/// </summary>
19+
IEnumerable<string> AvailableIcons { get; }
20+
}

src/DiagramForge/DiagramForge.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
<PackageReference Include="MinVer" PrivateAssets="All" />
1212
</ItemGroup>
1313

14+
<ItemGroup>
15+
<InternalsVisibleTo Include="DiagramForge.Tests" />
16+
</ItemGroup>
17+
1418
<PropertyGroup>
1519
<TargetFramework>net10.0</TargetFramework>
1620
<ImplicitUsings>enable</ImplicitUsings>

src/DiagramForge/DiagramRenderer.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using DiagramForge.Abstractions;
3+
using DiagramForge.Icons;
34
using DiagramForge.Layout;
45
using DiagramForge.Models;
56
using DiagramForge.Parsers.Conceptual;
@@ -29,6 +30,12 @@ public sealed class DiagramRenderer
2930
private readonly ISvgRenderer _svgRenderer;
3031
private readonly Theme _defaultTheme;
3132

33+
/// <summary>
34+
/// Icon registry used to resolve <see cref="Node.IconRef"/> references to
35+
/// <see cref="DiagramIcon"/> instances before rendering.
36+
/// </summary>
37+
public IconRegistry IconRegistry { get; } = new();
38+
3239
/// <summary>
3340
/// Creates a <see cref="DiagramRenderer"/> with the default parser set, layout engine, and theme.
3441
/// </summary>
@@ -58,10 +65,24 @@ public DiagramRenderer(
5865
_layoutEngine = layoutEngine;
5966
_svgRenderer = svgRenderer;
6067
_defaultTheme = defaultTheme;
68+
69+
// Register built-in Mermaid architecture icons (cloud, database, disk, internet, server).
70+
IconRegistry.RegisterPack("builtin", new BuiltInArchitectureIconProvider());
6171
}
6272

6373
// ── Public API ────────────────────────────────────────────────────────────
6474

75+
/// <summary>
76+
/// Registers a named icon pack. Icons can then be referenced in diagrams as
77+
/// <c>packName:icon-name</c>.
78+
/// </summary>
79+
/// <returns>This renderer, for fluent chaining.</returns>
80+
public DiagramRenderer RegisterIconPack(string packName, IIconProvider provider)
81+
{
82+
IconRegistry.RegisterPack(packName, provider);
83+
return this;
84+
}
85+
6586
/// <summary>
6687
/// Converts <paramref name="diagramText"/> to an SVG string using the default theme.
6788
/// The correct parser is selected automatically based on the diagram text.
@@ -161,6 +182,7 @@ public string Render(string diagramText, Theme? theme, string? paletteJson, bool
161182
diagram.LayoutHints.EdgeRouting = frontmatter.EdgeRouting.Value;
162183
}
163184

185+
ResolveIcons(diagram);
164186
_layoutEngine.Layout(diagram, effectiveTheme);
165187
return _svgRenderer.Render(diagram, effectiveTheme);
166188
}
@@ -183,6 +205,26 @@ public DiagramRenderer RegisterParser(IDiagramParser parser)
183205

184206
// ── Private helpers ───────────────────────────────────────────────────────
185207

208+
private void ResolveIcons(Diagram diagram)
209+
{
210+
foreach (var node in diagram.Nodes.Values)
211+
{
212+
if (node.IconRef is not null)
213+
{
214+
var icon = IconRegistry.Resolve(node.IconRef);
215+
if (icon is not null)
216+
{
217+
// Sanitize the SVG content before placing it into the output SVG
218+
// to prevent XSS / injection from untrusted diagram text or icon providers.
219+
string? sanitized = SvgIconSanitizer.Sanitize(icon.SvgContent);
220+
node.ResolvedIcon = sanitized is not null
221+
? icon with { SvgContent = sanitized }
222+
: null;
223+
}
224+
}
225+
}
226+
}
227+
186228
private IDiagramParser? FindParser(string diagramText)
187229
{
188230
foreach (var parser in _parsers)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using DiagramForge.Abstractions;
2+
using DiagramForge.Models;
3+
4+
namespace DiagramForge.Icons;
5+
6+
/// <summary>
7+
/// Provides the five built-in Mermaid architecture diagram icons:
8+
/// <c>cloud</c>, <c>database</c>, <c>disk</c>, <c>internet</c>, and <c>server</c>.
9+
/// </summary>
10+
/// <remarks>
11+
/// SVG paths are designed to render clearly at 24×24 viewBox, matching Mermaid's
12+
/// built-in icon set for architecture diagrams.
13+
/// </remarks>
14+
internal sealed class BuiltInArchitectureIconProvider : IIconProvider
15+
{
16+
private const string ViewBox = "0 0 24 24";
17+
18+
private static readonly Dictionary<string, string> Icons = new(StringComparer.OrdinalIgnoreCase)
19+
{
20+
["cloud"] =
21+
"""<path d="M18.74 10.04A6.87 6.87 0 0 0 12 4C9.34 4 7.05 5.64 5.9 8.04A5.5 5.5 0 0 0 1 14c0 3.31 2.47 6 5.5 6h11.93c2.53 0 4.58-2.24 4.58-5 0-2.64-1.88-4.78-4.27-4.96z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>""",
22+
23+
["database"] =
24+
"""<g fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="12" cy="5.5" rx="8" ry="3.5"/><path d="M4 5.5v13c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5v-13"/><path d="M4 12c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5"/></g>""",
25+
26+
["disk"] =
27+
"""<g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="9"/></g>""",
28+
29+
["internet"] =
30+
"""<g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><ellipse cx="12" cy="12" rx="4" ry="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M4.93 5h14.14M4.93 19h14.14"/></g>""",
31+
32+
["server"] =
33+
"""<g fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></g>""",
34+
};
35+
36+
public DiagramIcon? GetIcon(string name)
37+
{
38+
if (Icons.TryGetValue(name, out var svgContent))
39+
return new DiagramIcon("builtin", name, ViewBox, svgContent);
40+
41+
return null;
42+
}
43+
44+
public IEnumerable<string> AvailableIcons => Icons.Keys;
45+
}

0 commit comments

Comments
 (0)