diff --git a/README.md b/README.md
index 61802be..3f83fb5 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ company.Add(new D2Shape("deepmind", "DeepMind", Shape.Rectangle));
company.Icon = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png";
-var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO");
+var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO");
var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection });
@@ -56,17 +56,71 @@ google -> alphabet: BELONGS_TO

# Documentation
+
+## Typed diagrams
+
+Special D2 shapes have typed helpers while still participating in the ordered
+`D2Statement` model:
+
+```csharp
+var users = new D2SqlTable("users", "Users")
+{
+ new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey),
+ new D2SqlColumn("email", "string", D2SqlConstraint.Unique),
+};
+
+var service = new D2Class("user_service", "User service")
+{
+ new D2ClassField("repository", "Repository", D2Visibility.Private),
+ new D2ClassMethod(
+ "find",
+ "User",
+ D2Visibility.Public,
+ new D2ClassParameter("id", "int")),
+};
+
+var request = new D2SequenceDiagram("request", "Find user")
+ .AddParticipant("client", "Client")
+ .AddParticipant("service", "Service")
+ .AddMessage("client", "service", "find(42)")
+ .AddMessage("service", "client", "User", Direction.From);
+
+var diagram = new D2Diagram(new D2Statement[] { users, service, request });
+```
+
+All style arguments are optional and named arguments keep declarations compact:
+
+```csharp
+var api = new D2Shape(
+ "api",
+ Shape: Shape.Rectangle,
+ Style: new D2Style(
+ Fill: "#f4a261",
+ BorderRadius: 8,
+ Font: D2Font.Mono,
+ Animated: true))
+{
+ Link = "https://example.com/docs#api",
+ Tooltip = "Open API docs",
+ Width = 240,
+ Height = 120,
+};
+```
+
## Supported
- [x] Shapes (nodes)
- [x] Connections (edges)
-- [x] Styles
+- [x] Full documented style catalog
- [x] Containers (nodes/links in nodes)
- [x] Arrow directions
- [x] Markdown / latex / block strings / code in shapes
- [x] Shape icons
-- [ ] SQL table shapes
-- [ ] Class shapes
-- [ ] Comments
+- [x] Shape dimensions, links, and tooltips
+- [x] Connection icons, styles, links, and tooltips
+- [x] Typed SQL table shapes and constraints
+- [x] Typed UML classes, members, parameters, and visibility
+- [x] Ordered sequence-diagram helpers
+- [x] Comments, root properties, composition boards, and raw escape hatches
# Inspiration & Thanks
- [Kreshnik/d2lang-js](https://github.com/Kreshnik/d2lang-js)
diff --git a/example/cli/Program.cs b/example/cli/Program.cs
index 0fc8269..c8dbfa4 100644
--- a/example/cli/Program.cs
+++ b/example/cli/Program.cs
@@ -9,8 +9,8 @@
company.Icon = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png";
-var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO");
+var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO");
var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection });
-Console.WriteLine(diagram.ToString());
\ No newline at end of file
+Console.WriteLine(diagram.ToString());
diff --git a/src/D2Class.cs b/src/D2Class.cs
new file mode 100644
index 0000000..17a5b22
--- /dev/null
+++ b/src/D2Class.cs
@@ -0,0 +1,198 @@
+using System.Collections;
+
+namespace d2;
+
+/// UML visibility prefixes supported by D2 class members.
+public enum D2Visibility
+{
+ /// No explicit prefix; D2 treats this as public.
+ Default,
+ /// Explicit public visibility (+).
+ Public,
+ /// Private visibility (-).
+ Private,
+ /// Protected visibility (#).
+ Protected,
+}
+
+/// A typed parameter displayed in a UML class method signature.
+public sealed record D2ClassParameter
+{
+ /// The parameter name.
+ public string Name { get; }
+ /// The parameter type.
+ public string Type { get; }
+
+ /// Creates a class method parameter.
+ public D2ClassParameter(string name, string type)
+ {
+ _ = D2Writer.Identifier(name);
+ if (string.IsNullOrWhiteSpace(type))
+ {
+ throw new ArgumentException("A class parameter type cannot be null, empty, or whitespace.", nameof(type));
+ }
+
+ Name = name;
+ Type = type;
+ }
+
+ internal string Display() => $"{Name} {Type}";
+}
+
+/// A field or method in a typed .
+public abstract class D2ClassMember
+{
+ /// The UML visibility of the member.
+ public D2Visibility Visibility { get; }
+
+ private protected D2ClassMember(D2Visibility visibility)
+ {
+ if (!Enum.IsDefined(typeof(D2Visibility), visibility))
+ {
+ throw new ArgumentOutOfRangeException(nameof(visibility), visibility, "Unknown UML visibility.");
+ }
+ Visibility = visibility;
+ }
+
+ private protected abstract string Signature { get; }
+
+ private protected abstract string? Result { get; }
+
+ internal IEnumerable Lines()
+ {
+ var displayKey = VisibilityPrefix(Visibility) + Signature;
+ var key = Visibility == D2Visibility.Default && this is D2ClassField
+ ? D2Writer.ObjectMemberIdentifier(displayKey)
+ : D2Writer.Identifier(displayKey);
+ return Result is null
+ ? new[] { key }
+ : new[] { $"{key}: {D2Writer.String(Result)}" };
+ }
+
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ private static string VisibilityPrefix(D2Visibility visibility) => visibility switch
+ {
+ D2Visibility.Default => string.Empty,
+ D2Visibility.Public => "+",
+ D2Visibility.Private => "-",
+ D2Visibility.Protected => "#",
+ _ => throw new ArgumentOutOfRangeException(nameof(visibility), visibility, "Unknown UML visibility."),
+ };
+}
+
+/// A typed field in a D2 UML class.
+public sealed class D2ClassField : D2ClassMember
+{
+ /// The field name.
+ public string Name { get; }
+ /// The optional field type.
+ public string? Type { get; }
+
+ /// Creates a class field.
+ public D2ClassField(string name, string? type = null, D2Visibility visibility = D2Visibility.Default)
+ : base(visibility)
+ {
+ _ = D2Writer.Identifier(name);
+ Name = name;
+ Type = type;
+ }
+
+ private protected override string Signature => Name;
+ private protected override string? Result => Type;
+}
+
+/// A typed method in a D2 UML class.
+public sealed class D2ClassMethod : D2ClassMember
+{
+ /// The method name.
+ public string Name { get; }
+ /// The optional return type; means void.
+ public string? ReturnType { get; }
+ /// The method parameters.
+ public IReadOnlyList Parameters { get; }
+
+ /// Creates a class method.
+ public D2ClassMethod(
+ string name,
+ string? returnType = null,
+ D2Visibility visibility = D2Visibility.Default,
+ params D2ClassParameter[] parameters)
+ : base(visibility)
+ {
+ _ = D2Writer.Identifier(name);
+ if (parameters is null) throw new ArgumentNullException(nameof(parameters));
+ if (parameters.Any(parameter => parameter is null))
+ {
+ throw new ArgumentException("A class method cannot contain a null parameter.", nameof(parameters));
+ }
+
+ Name = name;
+ ReturnType = returnType;
+ Parameters = parameters.ToList();
+ }
+
+ private protected override string Signature
+ => $"{Name}({string.Join(", ", Parameters.Select(parameter => parameter.Display()))})";
+
+ private protected override string? Result => ReturnType;
+}
+
+/// A typed D2 UML class shape.
+public sealed record D2Class : D2Statement, IEnumerable
+{
+ private readonly List _members = new();
+
+ /// The class key.
+ public string Name { get; }
+ /// An optional displayed class label.
+ public string? Label { get; set; }
+ /// Typed styles applied to the class.
+ public D2Style? Style { get; set; }
+ /// An optional click destination.
+ public string? Link { get; set; }
+ /// Optional hover text.
+ public string? Tooltip { get; set; }
+ /// The class's ordered members.
+ public IReadOnlyList Members => _members;
+
+ /// Creates an empty UML class.
+ public D2Class(string name, string? label = null)
+ {
+ _ = D2Writer.Reference(name);
+ Name = name;
+ Label = label;
+ }
+
+ /// Adds a typed member. Supports collection initializer syntax.
+ public void Add(D2ClassMember member)
+ {
+ if (member is null) throw new ArgumentNullException(nameof(member));
+ _members.Add(member);
+ }
+
+ internal override IEnumerable Lines()
+ {
+ var shape = new D2Shape(Name, Label, Shape.Class, Style)
+ {
+ Link = Link,
+ Tooltip = Tooltip,
+ };
+ foreach (var member in _members) shape.Add(new MemberStatement(member));
+ return shape.Lines();
+ }
+
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ ///
+ public IEnumerator GetEnumerator() => _members.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ private sealed record MemberStatement(D2ClassMember Member) : D2Statement
+ {
+ internal override IEnumerable Lines() => Member.Lines();
+ }
+}
diff --git a/src/D2Connection.cs b/src/D2Connection.cs
index 8653cdc..528ab48 100644
--- a/src/D2Connection.cs
+++ b/src/D2Connection.cs
@@ -13,6 +13,18 @@ public record class D2Connection(
public IReadOnlyList Statements => _statements;
+ /// An optional icon URL displayed on this connection.
+ public string? Icon { get; set; }
+
+ /// An optional destination opened when this connection is clicked.
+ public string? Link { get; set; }
+
+ /// Optional text shown when this connection is hovered.
+ public string? Tooltip { get; set; }
+
+ /// Optional typed styles for this connection.
+ public D2Style? Style { get; set; }
+
public void Add(D2Property property) => Add((D2Statement)property);
public void Add(D2Statement statement)
@@ -34,14 +46,35 @@ internal override IEnumerable Lines()
@base += $": {D2Writer.String(Label!)}";
}
- if (_statements.Count == 0)
+ var properties = _statements.SelectMany(statement => statement.Lines()).ToList();
+ if (Icon is not null)
+ {
+ properties.Add($"icon: {D2Writer.String(Icon)}");
+ }
+
+ if (Link is not null)
+ {
+ properties.Add($"link: {D2Writer.String(Link)}");
+ }
+
+ if (Tooltip is not null)
+ {
+ properties.Add($"tooltip: {D2Writer.String(Tooltip)}");
+ }
+
+ if (Style is not null)
+ {
+ properties.AddRange(Style.Lines());
+ }
+
+ if (properties.Count == 0)
{
return new[] { @base };
}
var openingLine = hasLabel ? $"{@base} {{" : $"{@base}: {{";
return new[] { openingLine }
- .Concat(D2Writer.Indent(_statements.SelectMany(statement => statement.Lines())))
+ .Concat(D2Writer.Indent(properties))
.Append("}");
}
diff --git a/src/D2DiagramBuilder.cs b/src/D2DiagramBuilder.cs
new file mode 100644
index 0000000..e69aca9
--- /dev/null
+++ b/src/D2DiagramBuilder.cs
@@ -0,0 +1,55 @@
+using System.Collections;
+
+namespace d2;
+
+/// A mutable fluent builder for an ordered .
+public sealed class D2DiagramBuilder : IEnumerable
+{
+ private readonly List _statements = new();
+
+ /// The statements currently in the builder.
+ public IReadOnlyList Statements => _statements;
+
+ /// Creates an empty diagram builder.
+ public D2DiagramBuilder()
+ {
+ }
+
+ /// Adds a statement. Supports collection initializer syntax.
+ public void Add(D2Statement statement)
+ {
+ if (statement is null) throw new ArgumentNullException(nameof(statement));
+ _statements.Add(statement);
+ }
+
+ /// Appends any statement and returns this builder.
+ public D2DiagramBuilder Then(D2Statement statement)
+ {
+ Add(statement);
+ return this;
+ }
+
+ /// Appends a shape and returns this builder.
+ public D2DiagramBuilder AddShape(
+ string name,
+ string? label = null,
+ Shape? shape = null,
+ D2Style? style = null)
+ => Then(new D2Shape(name, label, shape, style));
+
+ /// Appends a connection and returns this builder.
+ public D2DiagramBuilder AddConnection(
+ string first,
+ string second,
+ Direction? direction = null,
+ string? label = null)
+ => Then(new D2Connection(first, second, direction ?? Direction.To, label));
+
+ /// Creates an immutable snapshot of the current statements.
+ public D2Diagram Build() => new(_statements);
+
+ ///
+ public IEnumerator GetEnumerator() => _statements.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/D2SequenceDiagram.cs b/src/D2SequenceDiagram.cs
new file mode 100644
index 0000000..8a0e999
--- /dev/null
+++ b/src/D2SequenceDiagram.cs
@@ -0,0 +1,87 @@
+using System.Collections;
+
+namespace d2;
+
+///
+/// An ordered D2 sequence diagram with fluent helpers for its common statements.
+/// Generic instances can still be added directly.
+///
+public sealed record D2SequenceDiagram : D2Statement, IEnumerable
+{
+ private readonly List _statements = new();
+
+ /// The sequence diagram key.
+ public string Name { get; }
+ /// An optional displayed label.
+ public string? Label { get; set; }
+ /// Typed styles applied to the sequence diagram.
+ public D2Style? Style { get; set; }
+ /// An optional click destination.
+ public string? Link { get; set; }
+ /// Optional hover text.
+ public string? Tooltip { get; set; }
+ /// The ordered sequence statements.
+ public IReadOnlyList Statements => _statements;
+
+ /// Creates an empty sequence diagram.
+ public D2SequenceDiagram(string name, string? label = null)
+ {
+ _ = D2Writer.Reference(name);
+ Name = name;
+ Label = label;
+ }
+
+ /// Adds an ordered statement. Supports collection initializer syntax.
+ public void Add(D2Statement statement)
+ {
+ if (statement is null) throw new ArgumentNullException(nameof(statement));
+ _statements.Add(statement);
+ }
+
+ /// Adds an actor or participant and returns this diagram.
+ public D2SequenceDiagram AddParticipant(string name, string? label = null, Shape? shape = null)
+ {
+ Add(new D2Shape(name, label, shape));
+ return this;
+ }
+
+ /// Adds an ordered message and returns this diagram.
+ public D2SequenceDiagram AddMessage(
+ string first,
+ string second,
+ string? label = null,
+ Direction? direction = null)
+ {
+ Add(new D2Connection(first, second, direction ?? Direction.To, label));
+ return this;
+ }
+
+ /// Adds a labeled sequence group and returns this diagram.
+ public D2SequenceDiagram AddGroup(string name, params D2Statement[] statements)
+ {
+ if (statements is null) throw new ArgumentNullException(nameof(statements));
+ var group = new D2Shape(name);
+ foreach (var statement in statements) group.Add(statement);
+ Add(group);
+ return this;
+ }
+
+ internal override IEnumerable Lines()
+ {
+ var shape = new D2Shape(Name, Label, Shape.SequenceDiagram, Style)
+ {
+ Link = Link,
+ Tooltip = Tooltip,
+ };
+ foreach (var statement in _statements) shape.Add(statement);
+ return shape.Lines();
+ }
+
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ ///
+ public IEnumerator GetEnumerator() => _statements.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/D2Shape.cs b/src/D2Shape.cs
index eb7bf76..2faebda 100644
--- a/src/D2Shape.cs
+++ b/src/D2Shape.cs
@@ -2,9 +2,15 @@
namespace d2;
+/// A general D2 shape or container.
+/// The shape key or dotted path.
+/// An optional displayed label.
+/// An optional D2 shape kind.
+/// Optional typed styles.
+/// An optional D2 relative placement.
public record class D2Shape(
string Name,
- string? Label,
+ string? Label = null,
Shape? Shape = default,
D2Style? Style = default,
string? Near = default
@@ -14,8 +20,21 @@ public record class D2Shape(
public IReadOnlyList Statements => _statements;
+ /// An optional icon URL.
public string Icon { get; set; } = string.Empty;
+ /// An optional destination opened when this shape is clicked.
+ public string? Link { get; set; }
+
+ /// Optional text shown when this shape is hovered.
+ public string? Tooltip { get; set; }
+
+ /// An optional fixed width for a non-container shape.
+ public int? Width { get; set; }
+
+ /// An optional fixed height for a non-container shape.
+ public int? Height { get; set; }
+
public void Add(D2Shape shape) => Add((D2Statement)shape);
public void Add(D2Connection connection) => Add((D2Statement)connection);
@@ -51,6 +70,19 @@ internal override IEnumerable Lines()
properties.Add($"near: {D2Writer.String(near)}");
}
+ AddPositiveDimension(properties, "width", Width);
+ AddPositiveDimension(properties, "height", Height);
+
+ if (Link is not null)
+ {
+ properties.Add($"link: {D2Writer.String(Link)}");
+ }
+
+ if (Tooltip is not null)
+ {
+ properties.Add($"tooltip: {D2Writer.String(Tooltip)}");
+ }
+
if (Style is not null)
{
properties.AddRange(Style.Lines());
@@ -73,4 +105,15 @@ IEnumerator IEnumerable.GetEnumerator()
IEnumerator IEnumerable.GetEnumerator()
=> _statements.OfType().GetEnumerator();
+
+ private static void AddPositiveDimension(ICollection properties, string name, int? value)
+ {
+ if (value is null) return;
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(name, value, $"{name} must be greater than zero.");
+ }
+
+ properties.Add($"{name}: {D2Writer.Integer(value.Value)}");
+ }
}
diff --git a/src/D2SqlTable.cs b/src/D2SqlTable.cs
new file mode 100644
index 0000000..e6d1ce9
--- /dev/null
+++ b/src/D2SqlTable.cs
@@ -0,0 +1,142 @@
+using System.Collections;
+
+namespace d2;
+
+/// A SQL-table constraint understood by D2.
+public sealed record D2SqlConstraint
+{
+ /// A primary-key constraint.
+ public static readonly D2SqlConstraint PrimaryKey = new("primary_key");
+ /// A foreign-key constraint.
+ public static readonly D2SqlConstraint ForeignKey = new("foreign_key");
+ /// A uniqueness constraint.
+ public static readonly D2SqlConstraint Unique = new("unique");
+
+ /// The D2 constraint value.
+ public string Value { get; }
+
+ private D2SqlConstraint(string value)
+ {
+ Value = value;
+ }
+
+ /// Creates a custom SQL constraint value.
+ /// The constraint text shown by D2.
+ public static D2SqlConstraint Custom(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("A SQL constraint cannot be null, empty, or whitespace.", nameof(value));
+ }
+
+ _ = D2Writer.String(value);
+ return new D2SqlConstraint(value);
+ }
+
+ ///
+ public override string ToString() => Value;
+}
+
+/// A typed column in a .
+public sealed record D2SqlColumn : D2Statement
+{
+ /// The column key.
+ public string Name { get; }
+ /// The SQL type displayed by D2.
+ public string Type { get; }
+ /// The column's constraints.
+ public IReadOnlyList Constraints { get; }
+
+ /// Creates a SQL column.
+ public D2SqlColumn(string name, string type, params D2SqlConstraint[] constraints)
+ {
+ _ = D2Writer.Identifier(name);
+ if (string.IsNullOrWhiteSpace(type))
+ {
+ throw new ArgumentException("A SQL column type cannot be null, empty, or whitespace.", nameof(type));
+ }
+ if (constraints is null)
+ {
+ throw new ArgumentNullException(nameof(constraints));
+ }
+ if (constraints.Any(constraint => constraint is null))
+ {
+ throw new ArgumentException("A SQL column cannot contain a null constraint.", nameof(constraints));
+ }
+
+ Name = name;
+ Type = type;
+ Constraints = constraints.ToList();
+ }
+
+ internal override IEnumerable Lines()
+ {
+ var column = $"{D2Writer.ObjectMemberIdentifier(Name)}: {D2Writer.String(Type)}";
+ if (Constraints.Count == 0)
+ {
+ return new[] { column };
+ }
+
+ var values = Constraints.Select(constraint => D2Writer.String(constraint.Value));
+ var constraintValue = Constraints.Count == 1
+ ? values.Single()
+ : $"[{string.Join("; ", values)}]";
+ return new[] { $"{column} {{ constraint: {constraintValue} }}" };
+ }
+
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+}
+
+/// A typed D2 sql_table shape.
+public sealed record D2SqlTable : D2Statement, IEnumerable
+{
+ private readonly List _columns = new();
+
+ /// The table key.
+ public string Name { get; }
+ /// An optional table label.
+ public string? Label { get; set; }
+ /// Typed styles applied to the table.
+ public D2Style? Style { get; set; }
+ /// An optional click destination.
+ public string? Link { get; set; }
+ /// Optional hover text.
+ public string? Tooltip { get; set; }
+ /// The table's ordered columns.
+ public IReadOnlyList Columns => _columns;
+
+ /// Creates an empty SQL table.
+ public D2SqlTable(string name, string? label = null)
+ {
+ _ = D2Writer.Reference(name);
+ Name = name;
+ Label = label;
+ }
+
+ /// Adds a typed column. Supports collection initializer syntax.
+ public void Add(D2SqlColumn column)
+ {
+ if (column is null) throw new ArgumentNullException(nameof(column));
+ _columns.Add(column);
+ }
+
+ internal override IEnumerable Lines()
+ {
+ var shape = new D2Shape(Name, Label, Shape.SqlTable, Style)
+ {
+ Link = Link,
+ Tooltip = Tooltip,
+ };
+ foreach (var column in _columns) shape.Add(column);
+ return shape.Lines();
+ }
+
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ ///
+ public IEnumerator GetEnumerator() => _columns.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/src/D2Style.cs b/src/D2Style.cs
index 06d72dc..de281f6 100644
--- a/src/D2Style.cs
+++ b/src/D2Style.cs
@@ -1,17 +1,89 @@
namespace d2;
+/// Patterns supported by D2's fill-pattern style.
+public enum D2FillPattern
+{
+ /// A dotted fill.
+ Dots,
+ /// A lined fill.
+ Lines,
+ /// A grain texture.
+ Grain,
+ /// Disables a fill pattern supplied by a theme.
+ None,
+}
+
+/// Fonts supported by D2's font style.
+public enum D2Font
+{
+ /// The D2 monospaced font.
+ Mono,
+}
+
+/// Transforms supported by D2's text-transform style.
+public enum D2TextTransform
+{
+ /// Converts text to uppercase.
+ Uppercase,
+ /// Converts text to lowercase.
+ Lowercase,
+ /// Capitalizes words.
+ Capitalize,
+ /// Disables a transform supplied by a theme.
+ None,
+}
+
+///
+/// The documented D2 style catalog. Every setting is optional, so callers can
+/// use named arguments to specify only the styles they need.
+///
+/// A CSS color or supported gradient.
+/// Stroke width from 1 through 15.
+/// A CSS color or supported gradient.
+/// Whether a shape has a shadow.
+/// Opacity from 0 through 1.
+/// Dash amount from 0 through 10.
+/// Whether a rectangle or square uses the 3D effect.
+/// The shape fill pattern.
+/// A nonnegative corner radius.
+/// Whether a shape uses the multiple-object effect.
+/// Whether a supported shape has a double border.
+/// The label font.
+/// Font size from 8 through 100.
+/// A CSS color or supported gradient.
+/// Whether a connection or shape is animated.
+/// Whether label text is bold.
+/// Whether label text is italic.
+/// Whether label text is underlined.
+/// The label casing transform.
public record class D2Style(
- string? Stroke,
- int? StrokeWidth,
- string? Fill,
- bool? Shadow,
- double? Opacity,
- int? StrokeDash,
- bool? ThreeD
-)
+ string? Stroke = null,
+ int? StrokeWidth = null,
+ string? Fill = null,
+ bool? Shadow = null,
+ double? Opacity = null,
+ int? StrokeDash = null,
+ bool? ThreeD = null,
+ D2FillPattern? FillPattern = null,
+ int? BorderRadius = null,
+ bool? Multiple = null,
+ bool? DoubleBorder = null,
+ D2Font? Font = null,
+ int? FontSize = null,
+ string? FontColor = null,
+ bool? Animated = null,
+ bool? Bold = null,
+ bool? Italic = null,
+ bool? Underline = null,
+ D2TextTransform? TextTransform = null)
{
+ /// Serializes this style as a D2 style block.
public IEnumerable Lines()
{
+ ValidateRange(StrokeWidth, 1, 15, nameof(StrokeWidth));
+ ValidateRange(StrokeDash, 0, 10, nameof(StrokeDash));
+ ValidateMinimum(BorderRadius, 0, nameof(BorderRadius));
+ ValidateRange(FontSize, 8, 100, nameof(FontSize));
if (Opacity is { } opacity &&
(double.IsNaN(opacity) || double.IsInfinity(opacity) || opacity is < 0 or > 1))
{
@@ -20,19 +92,104 @@ public IEnumerable Lines()
var styles = new List();
- if (Stroke is not null) styles.Add($"stroke: {D2Writer.String(Stroke)}");
- if (StrokeWidth is not null) styles.Add($"stroke-width: {D2Writer.Integer(StrokeWidth.Value)}");
- if (Fill is not null) styles.Add($"fill: {D2Writer.String(Fill)}");
- if (Shadow is not null) styles.Add($"shadow: {D2Writer.Boolean(Shadow.Value)}");
- if (Opacity is not null) styles.Add($"opacity: {D2Writer.Number(Opacity.Value)}");
- if (StrokeDash is not null) styles.Add($"stroke-dash: {D2Writer.Integer(StrokeDash.Value)}");
- if (ThreeD is not null) styles.Add($"3d: {D2Writer.Boolean(ThreeD.Value)}");
+ AddString(styles, "stroke", Stroke);
+ AddInteger(styles, "stroke-width", StrokeWidth);
+ AddString(styles, "fill", Fill);
+ AddBoolean(styles, "shadow", Shadow);
+ AddNumber(styles, "opacity", Opacity);
+ AddInteger(styles, "stroke-dash", StrokeDash);
+ AddBoolean(styles, "3d", ThreeD);
+ AddEnum(styles, "fill-pattern", FillPattern, FillPatternValue);
+ AddInteger(styles, "border-radius", BorderRadius);
+ AddBoolean(styles, "multiple", Multiple);
+ AddBoolean(styles, "double-border", DoubleBorder);
+ AddEnum(styles, "font", Font, FontValue);
+ AddInteger(styles, "font-size", FontSize);
+ AddString(styles, "font-color", FontColor);
+ AddBoolean(styles, "animated", Animated);
+ AddBoolean(styles, "bold", Bold);
+ AddBoolean(styles, "italic", Italic);
+ AddBoolean(styles, "underline", Underline);
+ AddEnum(styles, "text-transform", TextTransform, TextTransformValue);
return styles.Count == 0
- ? new List()
+ ? Array.Empty()
: D2Writer.Object("style", null, styles);
}
- public override string ToString()
- => string.Join(Environment.NewLine, Lines());
+ ///
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ private static void AddString(ICollection styles, string name, string? value)
+ {
+ if (value is not null) styles.Add($"{name}: {D2Writer.String(value)}");
+ }
+
+ private static void AddInteger(ICollection styles, string name, int? value)
+ {
+ if (value is not null) styles.Add($"{name}: {D2Writer.Integer(value.Value)}");
+ }
+
+ private static void AddNumber(ICollection styles, string name, double? value)
+ {
+ if (value is not null) styles.Add($"{name}: {D2Writer.Number(value.Value)}");
+ }
+
+ private static void AddBoolean(ICollection styles, string name, bool? value)
+ {
+ if (value is not null) styles.Add($"{name}: {D2Writer.Boolean(value.Value)}");
+ }
+
+ private static void AddEnum(
+ ICollection styles,
+ string name,
+ T? value,
+ Func serialize)
+ where T : struct
+ {
+ if (value is not null) styles.Add($"{name}: {serialize(value.Value)}");
+ }
+
+ private static void ValidateRange(int? value, int minimum, int maximum, string name)
+ {
+ if (value is { } actual && (actual < minimum || actual > maximum))
+ {
+ throw new ArgumentOutOfRangeException(name, actual, $"{name} must be between {minimum} and {maximum}.");
+ }
+ }
+
+ private static void ValidateMinimum(int? value, int minimum, string name)
+ {
+ if (value is { } actual && actual < minimum)
+ {
+ throw new ArgumentOutOfRangeException(name, actual, $"{name} must be at least {minimum}.");
+ }
+ }
+
+ private static string FillPatternValue(D2FillPattern value) => value switch
+ {
+ D2FillPattern.Dots => "dots",
+ D2FillPattern.Lines => "lines",
+ D2FillPattern.Grain => "grain",
+ D2FillPattern.None => "none",
+ _ => throw UnknownEnum(nameof(FillPattern), value),
+ };
+
+ private static string FontValue(D2Font value) => value switch
+ {
+ D2Font.Mono => "mono",
+ _ => throw UnknownEnum(nameof(Font), value),
+ };
+
+ private static string TextTransformValue(D2TextTransform value) => value switch
+ {
+ D2TextTransform.Uppercase => "uppercase",
+ D2TextTransform.Lowercase => "lowercase",
+ D2TextTransform.Capitalize => "capitalize",
+ D2TextTransform.None => "none",
+ _ => throw UnknownEnum(nameof(TextTransform), value),
+ };
+
+ private static ArgumentOutOfRangeException UnknownEnum(string name, T value)
+ => new(name, value, $"Unknown {name} value.");
}
diff --git a/src/D2Writer.cs b/src/D2Writer.cs
index f3c4901..f6f269a 100644
--- a/src/D2Writer.cs
+++ b/src/D2Writer.cs
@@ -18,6 +18,14 @@ internal static class D2Writer
"^[\\p{L}\\p{N}_][\\p{L}\\p{N} _./-]*$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
+ private static readonly HashSet ReservedObjectKeys = new(
+ new[]
+ {
+ "class", "constraint", "direction", "height", "icon", "label", "link",
+ "near", "shape", "style", "tooltip", "width",
+ },
+ StringComparer.OrdinalIgnoreCase);
+
internal static string Reference(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -47,6 +55,12 @@ internal static string Identifier(string value)
return IdentifierSegment(value);
}
+ internal static string ObjectMemberIdentifier(string value)
+ {
+ var identifier = Identifier(value);
+ return ReservedObjectKeys.Contains(value) ? Quoted(value) : identifier;
+ }
+
internal static string String(string value)
{
if (value is null)
diff --git a/src/Direction.cs b/src/Direction.cs
index d3012b2..33c3d31 100644
--- a/src/Direction.cs
+++ b/src/Direction.cs
@@ -2,10 +2,27 @@ namespace d2;
public abstract record Direction(string Value)
{
- public readonly static To TO = new();
- public readonly static From FROM = new();
- public readonly static Both BOTH = new();
- public readonly static None NONE = new();
+ /// A connection pointing from the first endpoint to the second.
+ public static readonly To To = new();
+ /// A connection pointing from the second endpoint to the first.
+ public static readonly From From = new();
+ /// A connection with arrowheads at both endpoints.
+ public static readonly Both Both = new();
+ /// A connection with no arrowhead.
+ public static readonly None None = new();
+
+ /// Legacy alias for .
+ [Obsolete("Use Direction.To instead.")]
+ public static readonly To TO = To;
+ /// Legacy alias for .
+ [Obsolete("Use Direction.From instead.")]
+ public static readonly From FROM = From;
+ /// Legacy alias for .
+ [Obsolete("Use Direction.Both instead.")]
+ public static readonly Both BOTH = Both;
+ /// Legacy alias for .
+ [Obsolete("Use Direction.None instead.")]
+ public static readonly None NONE = None;
public sealed override string ToString() => Value;
}
diff --git a/src/Utils.cs b/src/Utils.cs
index 6c59b03..696f889 100644
--- a/src/Utils.cs
+++ b/src/Utils.cs
@@ -1,6 +1,6 @@
namespace d2;
-public static class Utils
+internal static class Utils
{
public static string StringifyBoolean(bool? value) => D2Writer.Boolean(value is true);
diff --git a/test/ExtensibleModelTests.cs b/test/ExtensibleModelTests.cs
index e5032d9..ff1a169 100644
--- a/test/ExtensibleModelTests.cs
+++ b/test/ExtensibleModelTests.cs
@@ -14,7 +14,7 @@ public void Diagram_PreservesMixedStatementOrder()
new D2Property("direction", "right"),
new D2Shape("actor", "Actor"),
new D2Comment("the request happens next"),
- new D2Connection("actor", "service", Direction.TO, "request"),
+ new D2Connection("actor", "service", Direction.To, "request"),
new D2Shape("service", "Service"),
});
@@ -35,9 +35,9 @@ public void Shape_PreservesMixedBodyStatementOrder()
{
new D2Shape("user", "User"),
new D2Shape("api", "API"),
- new D2Connection("user", "api", Direction.TO, "sign in"),
+ new D2Connection("user", "api", Direction.To, "sign in"),
new D2Comment("the response must remain after the request"),
- new D2Connection("api", "user", Direction.TO, "session"),
+ new D2Connection("api", "user", Direction.To, "session"),
};
Assert.AreEqual(
@@ -109,7 +109,7 @@ public void RootProperties_SafelySerializeNestedConfiguration()
[TestMethod]
public void Connection_SupportsOrderedGenericProperties()
{
- var connection = new D2Connection("client", "server", Direction.TO, "call")
+ var connection = new D2Connection("client", "server", Direction.To, "call")
{
new D2Property("style", new D2Statement[]
{
@@ -139,7 +139,7 @@ public void Boards_SupportNestedLayersScenariosAndSteps()
var steps = new D2BoardCollection(D2BoardKind.Steps)
{
new D2Board("1") { new D2Shape("queued", null) },
- new D2Board("2") { new D2Connection("queued", "done", Direction.TO) },
+ new D2Board("2") { new D2Connection("queued", "done", Direction.To) },
};
var scenarios = new D2BoardCollection(D2BoardKind.Scenarios)
{
@@ -154,7 +154,7 @@ public void Boards_SupportNestedLayersScenariosAndSteps()
new D2Board("detail")
{
new D2Property("direction", "right"),
- new D2Connection("client", "worker", Direction.TO),
+ new D2Connection("client", "worker", Direction.To),
scenarios,
},
};
@@ -201,7 +201,7 @@ public void GenericNamesAreValidatedAndNumbersMustBeFinite()
[TestMethod]
public void ExtensibleDocument_PassesD2ValidationWhenCliIsAvailable()
{
- var connection = new D2Connection("client", "server", Direction.TO, "calls # safely")
+ var connection = new D2Connection("client", "server", Direction.To, "calls # safely")
{
new D2Property("style", new D2Statement[]
{
@@ -220,7 +220,7 @@ public void ExtensibleDocument_PassesD2ValidationWhenCliIsAvailable()
{
new D2Board("failover")
{
- new D2Connection("database", "replica", Direction.TO, "replicates"),
+ new D2Connection("database", "replica", Direction.To, "replicates"),
},
},
},
diff --git a/test/TypedFeatureTests.cs b/test/TypedFeatureTests.cs
new file mode 100644
index 0000000..0c7ab5d
--- /dev/null
+++ b/test/TypedFeatureTests.cs
@@ -0,0 +1,325 @@
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace Tests;
+
+[TestClass]
+public class TypedFeatureTests
+{
+ [TestMethod]
+ public void Style_SerializesTheDocumentedCatalog()
+ {
+ var style = new D2Style(
+ Stroke: "#123456",
+ StrokeWidth: 2,
+ Fill: "linear-gradient(#fff, #000)",
+ Shadow: true,
+ Opacity: 0.75,
+ StrokeDash: 3,
+ ThreeD: false,
+ FillPattern: D2FillPattern.Dots,
+ BorderRadius: 999,
+ Multiple: true,
+ DoubleBorder: true,
+ Font: D2Font.Mono,
+ FontSize: 24,
+ FontColor: "#abcdef",
+ Animated: true,
+ Bold: false,
+ Italic: true,
+ Underline: true,
+ TextTransform: D2TextTransform.Capitalize);
+
+ Assert.AreEqual(
+ Lines(
+ "style: {",
+ " stroke: \"#123456\"",
+ " stroke-width: 2",
+ " fill: \"linear-gradient(#fff, #000)\"",
+ " shadow: true",
+ " opacity: 0.75",
+ " stroke-dash: 3",
+ " 3d: false",
+ " fill-pattern: dots",
+ " border-radius: 999",
+ " multiple: true",
+ " double-border: true",
+ " font: mono",
+ " font-size: 24",
+ " font-color: \"#abcdef\"",
+ " animated: true",
+ " bold: false",
+ " italic: true",
+ " underline: true",
+ " text-transform: capitalize",
+ "}"),
+ style.ToString());
+ }
+
+ [TestMethod]
+ public void Style_ValidatesDocumentedRangesAndEnumValues()
+ {
+ Assert.ThrowsExactly(() => new D2Style(StrokeWidth: 0).ToString());
+ Assert.ThrowsExactly(() => new D2Style(StrokeWidth: 16).ToString());
+ Assert.ThrowsExactly(() => new D2Style(StrokeDash: -1).ToString());
+ Assert.ThrowsExactly(() => new D2Style(StrokeDash: 11).ToString());
+ Assert.ThrowsExactly(() => new D2Style(BorderRadius: -1).ToString());
+ Assert.ThrowsExactly(() => new D2Style(FontSize: 7).ToString());
+ Assert.ThrowsExactly(() => new D2Style(FontSize: 101).ToString());
+ Assert.ThrowsExactly(() => new D2Style(FillPattern: (D2FillPattern)100).ToString());
+ Assert.ThrowsExactly(() => new D2Style(Font: (D2Font)100).ToString());
+ Assert.ThrowsExactly(() => new D2Style(TextTransform: (D2TextTransform)100).ToString());
+ }
+
+ [TestMethod]
+ public void ShapeAndConnection_HaveTypedInteractiveAndLayoutProperties()
+ {
+ var shape = new D2Shape("docs", Shape: Shape.Page, Style: new D2Style(Fill: "#ffffff"))
+ {
+ Icon = "https://example.com/icon.svg#logo",
+ Link = "https://example.com/docs#start",
+ Tooltip = "Open: ${safe}",
+ Width = 320,
+ Height = 180,
+ };
+ var connection = new D2Connection("client", "docs", Direction.To, "read")
+ {
+ Icon = "https://example.com/edge.svg#read",
+ Link = "https://example.com/edge#read",
+ Tooltip = "Read # docs",
+ Style = new D2Style(Stroke: "#123456", Animated: true),
+ };
+
+ Assert.AreEqual(
+ Lines(
+ "docs: {",
+ " icon: \"https://example.com/icon.svg#logo\"",
+ " shape: page",
+ " width: 320",
+ " height: 180",
+ " link: \"https://example.com/docs#start\"",
+ " tooltip: \"Open: \\${safe}\"",
+ " style: {",
+ " fill: \"#ffffff\"",
+ " }",
+ "}"),
+ shape.ToString());
+ Assert.AreEqual(
+ Lines(
+ "client -> docs: read {",
+ " icon: \"https://example.com/edge.svg#read\"",
+ " link: \"https://example.com/edge#read\"",
+ " tooltip: \"Read # docs\"",
+ " style: {",
+ " stroke: \"#123456\"",
+ " animated: true",
+ " }",
+ "}"),
+ connection.ToString());
+
+ Assert.ThrowsExactly(() => new D2Shape("bad") { Width = 0 }.ToString());
+ Assert.ThrowsExactly(() => new D2Shape("bad") { Height = -1 }.ToString());
+ }
+
+ [TestMethod]
+ public void SqlTable_SerializesTypedColumnsConstraintsAndReservedNames()
+ {
+ var table = new D2SqlTable("users", "User records")
+ {
+ new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey, D2SqlConstraint.Unique),
+ new D2SqlColumn("account_id", "uuid", D2SqlConstraint.ForeignKey),
+ new D2SqlColumn("label", "timestamp with time zone", D2SqlConstraint.Custom("not null")),
+ };
+
+ Assert.AreEqual(
+ Lines(
+ "users: User records {",
+ " id: int { constraint: [primary_key; unique] }",
+ " account_id: uuid { constraint: foreign_key }",
+ " \"label\": timestamp with time zone { constraint: not null }",
+ " shape: sql_table",
+ "}"),
+ table.ToString());
+ }
+
+ [TestMethod]
+ public void Class_SerializesFieldsMethodsParametersAndVisibility()
+ {
+ var @class = new D2Class("parser", "D2 Parser")
+ {
+ new D2ClassField("reader", "io.RuneReader", D2Visibility.Public),
+ new D2ClassField("lookahead", "[]rune", D2Visibility.Private),
+ new D2ClassField("label", "string", D2Visibility.Protected),
+ new D2ClassMethod(
+ "peek",
+ "(r rune, eof bool)",
+ D2Visibility.Public,
+ new D2ClassParameter("count", "uint64")),
+ new D2ClassMethod("commit"),
+ };
+
+ Assert.AreEqual(
+ Lines(
+ "parser: D2 Parser {",
+ " \"+reader\": io.RuneReader",
+ " \"-lookahead\": \"[]rune\"",
+ " \"#label\": string",
+ " \"+peek(count uint64)\": \"(r rune, eof bool)\"",
+ " \"commit()\"",
+ " shape: class",
+ "}"),
+ @class.ToString());
+ }
+
+ [TestMethod]
+ public void SequenceDiagram_PreservesFluentStatementOrder()
+ {
+ var sequence = new D2SequenceDiagram("login", "Login")
+ .AddParticipant("user", "User", Shape.Person)
+ .AddParticipant("api", "API")
+ .AddMessage("user", "api", "sign in")
+ .AddGroup(
+ "retry",
+ new D2Connection("api", "api", Direction.To, "refresh"))
+ .AddMessage("api", "user", "session", Direction.From);
+
+ Assert.AreEqual(
+ Lines(
+ "login: Login {",
+ " user: User {",
+ " shape: person",
+ " }",
+ " api: API",
+ " user -> api: sign in",
+ " retry: {",
+ " api -> api: refresh",
+ " }",
+ " api <- user: session",
+ " shape: sequence_diagram",
+ "}"),
+ sequence.ToString());
+ }
+
+ [TestMethod]
+ public void Builder_ProvidesFluentAndCollectionInitializerPaths()
+ {
+ var builder = new D2DiagramBuilder
+ {
+ new D2Comment("start")
+ };
+ var diagram = builder
+ .AddShape("client", "Client")
+ .AddShape("server")
+ .AddConnection("client", "server", label: "call")
+ .Build();
+
+ Assert.AreEqual(
+ Lines("# start", "client: Client", "server", "client -> server: call"),
+ diagram.ToString());
+ }
+
+ [TestMethod]
+ public void PascalCaseDirectionsPreserveLegacyInstances()
+ {
+#pragma warning disable CS0618
+ Assert.AreSame(Direction.To, Direction.TO);
+ Assert.AreSame(Direction.From, Direction.FROM);
+ Assert.AreSame(Direction.Both, Direction.BOTH);
+ Assert.AreSame(Direction.None, Direction.NONE);
+#pragma warning restore CS0618
+ }
+
+ [TestMethod]
+ public void TypedFeatures_PassD2ValidationWhenCliIsAvailable()
+ {
+ var styled = new D2Shape("styled", Style: new D2Style(
+ Stroke: "#123456",
+ StrokeWidth: 2,
+ Fill: "#ffffff",
+ Shadow: true,
+ Opacity: 0.8,
+ StrokeDash: 2,
+ ThreeD: true,
+ FillPattern: D2FillPattern.Grain,
+ BorderRadius: 12,
+ Multiple: true,
+ DoubleBorder: true,
+ Font: D2Font.Mono,
+ FontSize: 20,
+ FontColor: "#222222",
+ Animated: true,
+ Bold: true,
+ Italic: false,
+ Underline: true,
+ TextTransform: D2TextTransform.Uppercase));
+ var table = new D2SqlTable("users")
+ {
+ new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey),
+ new D2SqlColumn("email", "string", D2SqlConstraint.Unique),
+ new D2SqlColumn("label", "timestamp with time zone", D2SqlConstraint.Custom("not null")),
+ };
+ var @class = new D2Class("service")
+ {
+ new D2ClassField("repository", "Repository", D2Visibility.Private),
+ new D2ClassMethod("find", "User", D2Visibility.Public, new D2ClassParameter("id", "int")),
+ };
+ var sequence = new D2SequenceDiagram("request")
+ .AddParticipant("client")
+ .AddParticipant("server")
+ .AddMessage("client", "server", "GET # user")
+ .AddMessage("server", "client", "User", Direction.From);
+ sequence.Link = "https://example.com/docs#sequence";
+ sequence.Tooltip = "Request flow";
+ var edge = new D2Connection("styled", "users", Direction.To, "opens")
+ {
+ Icon = "https://example.com/edge.svg#users",
+ Link = "https://example.com/docs#users",
+ Tooltip = "Open users",
+ Style = new D2Style(Stroke: "#ff0000", Animated: true),
+ };
+ var diagram = new D2Diagram(new D2Statement[] { styled, table, @class, sequence, edge });
+
+ ValidateWithD2(diagram.ToString());
+ }
+
+ private static void ValidateWithD2(string source)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"d2lang-cs-typed-{Guid.NewGuid():N}.d2");
+ File.WriteAllText(path, source);
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "d2",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ }
+ };
+ process.StartInfo.ArgumentList.Add("validate");
+ process.StartInfo.ArgumentList.Add(path);
+ try
+ {
+ process.Start();
+ }
+ catch (Win32Exception)
+ {
+ Assert.Inconclusive("The D2 CLI is not installed; parser-backed validation was skipped.");
+ return;
+ }
+
+ var output = process.StandardOutput.ReadToEnd();
+ var error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ Assert.AreEqual(0, process.ExitCode, $"d2 validate failed.{Environment.NewLine}{output}{error}{Environment.NewLine}{source}");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
+}
diff --git a/test/UnitTests.cs b/test/UnitTests.cs
index a9e3238..9a67c4d 100644
--- a/test/UnitTests.cs
+++ b/test/UnitTests.cs
@@ -18,7 +18,7 @@ public void Diagram_PreservesSimpleReadableOutput()
new D2Shape("deepmind", "DeepMind", Shape.Rectangle),
};
- var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO");
+ var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO");
var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection });
var expected = Lines(
@@ -87,7 +87,7 @@ public void Connection_QuotesEndpointsAndLabel()
var connection = new D2Connection(
"source.node",
"target # node",
- Direction.BOTH,
+ Direction.Both,
"uses: \"secure\" ${token}");
Assert.AreEqual(
@@ -179,7 +179,7 @@ public void GeneratedDiagram_PassesD2ValidationWhenCliIsAvailable()
var target = new D2Shape("target#2", "Target: database", Shape.Cylinder);
var diagram = new D2Diagram(
new[] { source, target },
- new[] { new D2Connection(source.Name, target.Name, Direction.TO, "calls # safely") });
+ new[] { new D2Connection(source.Name, target.Name, Direction.To, "calls # safely") });
var path = Path.Combine(Path.GetTempPath(), $"d2lang-cs-{Guid.NewGuid():N}.d2");
File.WriteAllText(path, diagram.ToString());