MermaidSerialiser.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. using System.Text;
  2. namespace RackPeek.Domain.Graph.Serialisers;
  3. /// <summary>
  4. /// Renders a <see cref="Graph"/> as a Mermaid flowchart string.
  5. /// Output is deterministic (nodes/edges in insertion order) so the
  6. /// same inventory always produces the same diagram — important for
  7. /// golden-file tests and for committing rendered diagrams to docs.
  8. /// </summary>
  9. public sealed class MermaidSerialiser {
  10. // Single neutral palette for a sleek monochrome look. Resource kind is
  11. // signalled by node shape, not colour, so diagrams stay calm even with
  12. // every kind of resource mixed in.
  13. private const string _nodeFill = "#1f2937"; // gray-800
  14. private const string _nodeStroke = "#52525b"; // zinc-600
  15. private const string _nodeText = "#e5e7eb"; // gray-200
  16. private const string _edgeStroke = "#52525b"; // zinc-600
  17. private const string _groupStroke = "#3f3f46"; // zinc-700
  18. private const string _groupText = "#a1a1aa"; // zinc-400
  19. private const string _nodeClass = "rpknode";
  20. private const string _groupClass = "rpkgroup";
  21. private const string _smallRowClass = "rpkrow";
  22. // Compact-mode (logical view) tuning. Small-row size controls how many
  23. // single-service host cards pack into one invisible row before wrapping.
  24. private const int _compactSmallRowSize = 4;
  25. private const int _compactTableColumns = 3;
  26. // Mermaid node shape per resource kind. Shape choice borrows from the
  27. // network-diagram conventions used by NetBox/draw.io/UniFi: hexagons for
  28. // security boundaries, stadiums for gateways, cylinders for compute,
  29. // circles for radios, etc. Looking at the silhouette alone should hint
  30. // at the role without colour or icons.
  31. private static readonly IReadOnlyDictionary<string, Shape> _shapes =
  32. new Dictionary<string, Shape>(StringComparer.OrdinalIgnoreCase) {
  33. // Physical / topology view shapes
  34. ["Firewall"] = new("{{\"", "\"}}"), // hexagon — boundary
  35. ["Router"] = new("([\"", "\"])"), // stadium — gateway
  36. ["Switch"] = new("[[\"", "\"]]"), // subroutine — distribution
  37. ["Server"] = new("[(\"", "\")]"), // cylinder — compute / storage
  38. ["AccessPoint"] = new("((\"", "\"))"), // circle — radio
  39. ["Ups"] = new("{\"", "\"}"), // rhombus — utility
  40. ["Desktop"] = new("(\"", "\")"), // rounded rect — endpoint
  41. ["Laptop"] = new("(\"", "\")"), // rounded rect — endpoint
  42. ["Other"] = new("[\"", "\"]"), // plain rect — uncategorised
  43. // Logical / service view shapes (don't appear with the physical
  44. // kinds in the same diagram, so shape reuse across views is OK)
  45. ["Service"] = new("[[\"", "\"]]"), // subroutine — consumable
  46. ["Hypervisor"] = new("([\"", "\"])"), // stadium — host gateway
  47. ["Vm"] = new("(\"", "\")"), // rounded — virtual machine
  48. ["Container"] = new("{{\"", "\"}}"), // hexagon — lightweight unit
  49. ["System"] = new("[\"", "\"]") // plain rect — fallback
  50. };
  51. private static readonly Shape _fallbackShape = new("[\"", "\"]");
  52. public string Serialise(Graph graph, string direction = "TD") {
  53. if (graph.RenderHint == GraphRenderHint.Compact)
  54. return SerialiseCompact(graph, direction);
  55. var sb = new StringBuilder();
  56. // Right-angle (Manhattan) edge routing — the visual signal that says
  57. // "this is a network diagram", borrowed from every serious topology
  58. // tool. Diagonal/curved lines read as "flowchart".
  59. //
  60. // Edge-label background is made transparent so connection labels read
  61. // as floating annotations rather than chunky chips that fight with
  62. // the line and the nodes for attention.
  63. // ELK renderer + orthogonal edge routing — Mermaid's default `dagre`
  64. // layout is fine for simple flowcharts but produces awkward arrow
  65. // landings on right-angle edges. ELK (Eclipse Layout Kernel) is the
  66. // engine NetBox/yEd/draw.io rely on for clean topology routing.
  67. //
  68. // Spacing values are generous on purpose — homelab diagrams read
  69. // better with air around nodes and between subnet/host clusters.
  70. // - `layout: elk` : use the Mermaid 11 ELK plugin (the
  71. // older `flowchart.defaultRenderer`
  72. // still works but is the legacy path).
  73. // - `elk.aspectRatio: 0.5` : ask ELK to favour tall over wide so
  74. // a host with dozens of services
  75. // doesn't fan out into a single row
  76. // kilometres long.
  77. // - `layered.wrapping.strategy : MULTI_EDGE
  78. // wraps an overlong layer into several
  79. // shorter ones — exactly what large
  80. // logical/service diagrams need.
  81. sb.AppendLine(
  82. "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'elk': {'algorithm': 'layered', 'aspectRatio': 0.5, 'layered.wrapping.strategy': 'MULTI_EDGE', 'layered.nodePlacement.strategy': 'BRANDES_KOEPF'}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
  83. sb.Append("flowchart ").AppendLine(direction);
  84. EmitClassDefs(sb);
  85. Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
  86. // Index groups & nodes for hierarchical emission.
  87. IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
  88. var childGroups = groups
  89. .GroupBy(g => g.ParentGroupId ?? string.Empty)
  90. .ToDictionary(g => g.Key, g => g.ToList());
  91. var groupsById = groups.ToDictionary(g => g.Id);
  92. HashSet<string> groupedNodeIds = new(
  93. groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
  94. // Emit top-level groups (parentGroupId == null/empty) — each recursively
  95. // contains its sub-groups and direct nodes.
  96. if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel))
  97. foreach (GraphGroup group in topLevel)
  98. EmitGroup(sb, group, childGroups, groupsById, graph.Nodes, idMap, indent: 1);
  99. // Emit any nodes that didn't fall into a group at the top level.
  100. foreach (GraphNode node in graph.Nodes) {
  101. if (groupedNodeIds.Contains(node.Id)) continue;
  102. EmitNode(sb, node, idMap, indent: 1);
  103. }
  104. if (graph.Edges.Count > 0) sb.AppendLine();
  105. foreach (GraphEdge edge in graph.Edges) {
  106. if (!idMap.TryGetValue(edge.Source, out var src) ||
  107. !idMap.TryGetValue(edge.Target, out var dst))
  108. continue;
  109. // Directional edges (runsOn, depends-on …) get an arrowhead so
  110. // the relationship reads correctly. Symmetric edges (port-to-port
  111. // physical connections) stay as plain lines.
  112. var connector = IsDirectional(edge.Kind) ? "-->" : "---";
  113. sb.Append(" ").Append(src);
  114. if (!string.IsNullOrWhiteSpace(edge.Label))
  115. sb.Append(' ').Append(connector).Append("|\"")
  116. .Append(Escape(edge.Label)).Append("\"|");
  117. else
  118. sb.Append(' ').Append(connector);
  119. sb.Append(' ').Append(dst).AppendLine();
  120. }
  121. // Dotted edges matching the dotted node borders. Labels float on top
  122. // (themeVariables.edgeLabelBackground=transparent) so the line stays
  123. // visually continuous through the label region.
  124. if (graph.Edges.Count > 0) {
  125. sb.AppendLine();
  126. sb.Append(" linkStyle default stroke:").Append(_edgeStroke)
  127. .AppendLine(",stroke-width:1.25px,stroke-dasharray:4 4,fill:none");
  128. }
  129. // Apply the group styling class to every subgraph id.
  130. foreach (GraphGroup group in groups) {
  131. sb.Append(" class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
  132. }
  133. return sb.ToString();
  134. }
  135. private void EmitGroup(
  136. StringBuilder sb,
  137. GraphGroup group,
  138. Dictionary<string, List<GraphGroup>> childGroups,
  139. Dictionary<string, GraphGroup> groupsById,
  140. IReadOnlyList<GraphNode> allNodes,
  141. Dictionary<string, string> idMap,
  142. int indent) {
  143. var pad = new string(' ', indent * 4);
  144. sb.Append(pad).Append("subgraph ").Append(group.Id)
  145. .Append(" [\"").Append(Escape(group.Label)).Append("\"]")
  146. .AppendLine();
  147. // Nested groups first
  148. if (childGroups.TryGetValue(group.Id, out List<GraphGroup>? children))
  149. foreach (GraphGroup child in children)
  150. EmitGroup(sb, child, childGroups, groupsById, allNodes, idMap, indent + 1);
  151. // Nodes that belong to this group directly (not via a child group)
  152. HashSet<string> nodesInChildren = new(
  153. (children ?? []).SelectMany(c => CollectAllNodeIds(c, childGroups)),
  154. StringComparer.OrdinalIgnoreCase);
  155. foreach (var nodeId in group.NodeIds) {
  156. if (nodesInChildren.Contains(nodeId)) continue;
  157. GraphNode? node = allNodes.FirstOrDefault(n =>
  158. string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
  159. if (node is null) continue;
  160. EmitNode(sb, node, idMap, indent + 1);
  161. }
  162. sb.Append(pad).AppendLine("end");
  163. }
  164. private static IEnumerable<string> CollectAllNodeIds(
  165. GraphGroup group,
  166. Dictionary<string, List<GraphGroup>> childGroups) {
  167. foreach (var id in group.NodeIds) yield return id;
  168. if (!childGroups.TryGetValue(group.Id, out List<GraphGroup>? children)) yield break;
  169. foreach (GraphGroup c in children)
  170. foreach (var id in CollectAllNodeIds(c, childGroups))
  171. yield return id;
  172. }
  173. private void EmitNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
  174. var safeId = idMap[node.Id];
  175. Shape shape = ResolveShape(node.Kind);
  176. var label = BuildLabel(node);
  177. sb.Append(new string(' ', indent * 4)).Append(safeId)
  178. .Append(shape.Open).Append(label).Append(shape.Close)
  179. .Append(":::").Append(_nodeClass)
  180. .AppendLine();
  181. }
  182. private static string BuildLabel(GraphNode node) {
  183. // Two-line label: resource name on top, optional subtitle below.
  184. // Each use case decides what's most useful as a subtitle (kind for
  185. // the topology view, ip[:port] for the logical view) — the serialiser
  186. // is agnostic.
  187. var name = Escape(node.Label);
  188. if (string.IsNullOrWhiteSpace(node.Subtitle)) return name;
  189. return $"{name}<br/>{Escape(node.Subtitle!)}";
  190. }
  191. private static void EmitClassDefs(StringBuilder sb) {
  192. // Dotted node borders + dotted edges (via linkStyle below) keep the
  193. // whole diagram visually quiet — solid borders feel heavier than the
  194. // information they convey.
  195. sb.Append(" classDef ").Append(_nodeClass)
  196. .Append(" fill:").Append(_nodeFill)
  197. .Append(",stroke:").Append(_nodeStroke)
  198. .Append(",color:").Append(_nodeText)
  199. .Append(",stroke-width:1px,stroke-dasharray:3 3")
  200. .AppendLine();
  201. // Group containers: dotted outline, no fill, muted title. The cluster
  202. // background/border theme variables in the init directive cover the
  203. // built-in Mermaid styling; this class adds the dashed outline.
  204. sb.Append(" classDef ").Append(_groupClass)
  205. .Append(" fill:none,stroke:").Append(_groupStroke)
  206. .Append(",color:").Append(_groupText)
  207. .Append(",stroke-width:1px,stroke-dasharray:3 3")
  208. .AppendLine();
  209. sb.AppendLine();
  210. }
  211. private static Dictionary<string, string> AssignSafeIds(IReadOnlyList<GraphNode> nodes) {
  212. // Mermaid node IDs must be a small alphabet (letters, digits, underscore).
  213. // Map resource names → deterministic safe IDs, suffixing on collision.
  214. var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  215. var taken = new HashSet<string>(StringComparer.Ordinal);
  216. foreach (GraphNode node in nodes) {
  217. var baseId = "n_" + Slug(node.Id);
  218. var candidate = baseId;
  219. var counter = 2;
  220. while (!taken.Add(candidate)) candidate = $"{baseId}_{counter++}";
  221. result[node.Id] = candidate;
  222. }
  223. return result;
  224. }
  225. private static string Slug(string value) {
  226. var sb = new StringBuilder(value.Length);
  227. foreach (var c in value)
  228. sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_');
  229. return sb.Length == 0 ? "node" : sb.ToString();
  230. }
  231. private static Shape ResolveShape(string kind) =>
  232. _shapes.TryGetValue(kind, out Shape shape) ? shape : _fallbackShape;
  233. private static string Escape(string value) =>
  234. value.Replace("\\", "\\\\").Replace("\"", "\\\"");
  235. private static readonly HashSet<string> _directionalEdgeKinds = new(StringComparer.OrdinalIgnoreCase) {
  236. "runsOn",
  237. "dependsOn"
  238. };
  239. private static bool IsDirectional(string kind) =>
  240. _directionalEdgeKinds.Contains(kind);
  241. private readonly record struct Shape(string Open, string Close);
  242. // ---------------------------------------------------------------------
  243. // Compact mode (logical view): each system becomes a single "host card"
  244. // whose label is an HTML table of its services. No edges are drawn —
  245. // subgraph containment carries the runs-on relationship. Sibling cards
  246. // are chained vertically via invisible ~~~ links so ELK doesn't fan
  247. // them out into a kilometre-wide row, and single-row hosts are packed
  248. // into invisible row subgraphs of N to use the horizontal space.
  249. // ---------------------------------------------------------------------
  250. private string SerialiseCompact(Graph graph, string direction) {
  251. var sb = new StringBuilder();
  252. // htmlLabels + securityLevel: 'loose' let us put raw HTML inside the
  253. // node labels. aspectRatio is set above 0.5 because compact mode
  254. // already wraps long sibling lists itself via the small-row packing.
  255. sb.AppendLine(
  256. "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 10, 'rankSpacing': 10, 'padding': 0, 'htmlLabels': true, 'subGraphTitleMargin': {'top': 0, 'bottom': 0}, 'titleTopMargin': 0}, 'securityLevel': 'loose', 'elk': {'algorithm': 'layered', 'padding': '[top=0,bottom=4,left=6,right=6]', 'spacing.nodeNode': 8, 'spacing.nodeNodeBetweenLayers': 8, 'spacing.componentComponent': 6, 'layered.spacing.nodeNodeBetweenLayers': 8, 'nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]'}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
  257. sb.Append("flowchart ").AppendLine(direction);
  258. EmitClassDefs(sb);
  259. sb.Append(" classDef ").Append(_smallRowClass)
  260. .AppendLine(" fill:none,stroke:none,color:transparent");
  261. sb.AppendLine();
  262. Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
  263. IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
  264. var childGroups = groups
  265. .GroupBy(g => g.ParentGroupId ?? string.Empty)
  266. .ToDictionary(g => g.Key, g => g.ToList());
  267. HashSet<string> groupedNodeIds = new(
  268. groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
  269. // Invisible chains and packed-row ids are collected during traversal
  270. // and emitted in a block at the end.
  271. var chains = new List<IReadOnlyList<string>>();
  272. var smallRowIds = new List<string>();
  273. void Emit(GraphGroup group, int indent) {
  274. var pad = new string(' ', indent * 4);
  275. sb.Append(pad).Append("subgraph ").Append(group.Id)
  276. .Append(" [\"").Append(Escape(group.Label)).Append("\"]").AppendLine();
  277. List<GraphGroup> subChildren =
  278. childGroups.TryGetValue(group.Id, out List<GraphGroup>? cs) ? cs : new();
  279. foreach (GraphGroup child in subChildren) Emit(child, indent + 1);
  280. if (subChildren.Count > 1)
  281. chains.Add(subChildren.Select(c => c.Id).ToList());
  282. HashSet<string> nodesInChildren = new(
  283. subChildren.SelectMany(c => CollectAllNodeIds(c, childGroups)),
  284. StringComparer.OrdinalIgnoreCase);
  285. // Partition the group's direct nodes into "big" cards (host with
  286. // multiple service rows) and "small" cards (no rows or one row).
  287. // Bigs get a dedicated row each; smalls pack horizontally.
  288. var bigs = new List<GraphNode>();
  289. var smalls = new List<GraphNode>();
  290. foreach (var nodeId in group.NodeIds) {
  291. if (nodesInChildren.Contains(nodeId)) continue;
  292. GraphNode? node = graph.Nodes.FirstOrDefault(n =>
  293. string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
  294. if (node is null) continue;
  295. if ((node.Rows?.Count ?? 0) > 1) bigs.Add(node);
  296. else smalls.Add(node);
  297. }
  298. var verticalChain = new List<string>();
  299. foreach (GraphNode b in bigs) {
  300. EmitCompactNode(sb, b, idMap, indent + 1);
  301. verticalChain.Add(idMap[b.Id]);
  302. }
  303. for (int i = 0, rowIdx = 0; i < smalls.Count; i += _compactSmallRowSize, rowIdx++) {
  304. var slice = smalls.Skip(i).Take(_compactSmallRowSize).ToList();
  305. // Single small host doesn't need an invisible row wrapper —
  306. // wrapping adds another nested subgraph (with its own
  307. // padding/title overhead) for no layout benefit.
  308. if (slice.Count == 1) {
  309. EmitCompactNode(sb, slice[0], idMap, indent + 1);
  310. verticalChain.Add(idMap[slice[0].Id]);
  311. continue;
  312. }
  313. var rowId = group.Id + "__srow" + rowIdx;
  314. smallRowIds.Add(rowId);
  315. verticalChain.Add(rowId);
  316. sb.Append(pad).Append(" subgraph ").Append(rowId).AppendLine(" [\" \"]");
  317. sb.Append(pad).Append(" direction LR").AppendLine();
  318. foreach (GraphNode s in slice)
  319. EmitCompactNode(sb, s, idMap, indent + 2);
  320. sb.Append(pad).AppendLine(" end");
  321. sb.Append(pad).Append(" ");
  322. sb.AppendJoin(" ~~~ ", slice.Select(s => idMap[s.Id]));
  323. sb.AppendLine();
  324. }
  325. if (verticalChain.Count > 1) chains.Add(verticalChain);
  326. sb.Append(pad).AppendLine("end");
  327. }
  328. if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel)) {
  329. foreach (GraphGroup g in topLevel) Emit(g, 1);
  330. if (topLevel.Count > 1)
  331. chains.Add(topLevel.Select(g => g.Id).ToList());
  332. }
  333. // Ungrouped nodes (uncommon in compact mode but render them sanely).
  334. foreach (GraphNode node in graph.Nodes) {
  335. if (groupedNodeIds.Contains(node.Id)) continue;
  336. EmitCompactNode(sb, node, idMap, 1);
  337. }
  338. // Invisible vertical chains last — these are what tell ELK to stack
  339. // siblings vertically instead of flowing into one long row.
  340. if (chains.Count > 0) sb.AppendLine();
  341. foreach (IReadOnlyList<string> chain in chains) {
  342. if (chain.Count < 2) continue;
  343. sb.Append(" ");
  344. sb.AppendJoin(" ~~~ ", chain);
  345. sb.AppendLine();
  346. }
  347. sb.AppendLine();
  348. foreach (GraphGroup group in groups)
  349. sb.Append(" class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
  350. foreach (var rowId in smallRowIds)
  351. sb.Append(" class ").Append(rowId).Append(' ').Append(_smallRowClass).AppendLine();
  352. return sb.ToString();
  353. }
  354. private void EmitCompactNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
  355. var safeId = idMap[node.Id];
  356. Shape shape = ResolveShape(node.Kind);
  357. var label = BuildCompactLabel(node);
  358. sb.Append(new string(' ', indent * 4)).Append(safeId)
  359. .Append(shape.Open).Append(label).Append(shape.Close)
  360. .Append(":::").Append(_nodeClass)
  361. .AppendLine();
  362. }
  363. private static string BuildCompactLabel(GraphNode node) {
  364. var sb = new StringBuilder();
  365. sb.Append("<div style='text-align:left;font-family:system-ui;padding:4px 6px'>");
  366. sb.Append("<div style='font-weight:600;font-size:14px'>");
  367. sb.Append(EscapeHtml(node.Label));
  368. if (!string.IsNullOrWhiteSpace(node.Subtitle)) {
  369. sb.Append(" - <span style='color:#9ca3af'>");
  370. sb.Append(EscapeHtml(node.Subtitle!));
  371. sb.Append("</span>");
  372. }
  373. sb.Append("</div>");
  374. if (node.Rows is { Count: > 0 }) {
  375. sb.Append("<hr style='border:none;border-top:1px dashed #52525b;margin:6px 0'>");
  376. sb.Append("<table style='border-collapse:collapse;font-size:11px'>");
  377. for (var i = 0; i < node.Rows.Count; i += _compactTableColumns) {
  378. sb.Append("<tr>");
  379. for (var c = 0; c < _compactTableColumns; c++) {
  380. var idx = i + c;
  381. if (idx >= node.Rows.Count) { sb.Append("<td></td>"); continue; }
  382. GraphNodeRow row = node.Rows[idx];
  383. sb.Append("<td style='padding:2px 10px 2px 0;white-space:nowrap'>");
  384. sb.Append("<span style='color:#e5e7eb'>").Append(EscapeHtml(row.Name)).Append("</span>");
  385. if (!string.IsNullOrEmpty(row.Detail))
  386. sb.Append("<span style='color:#71717a'>").Append(EscapeHtml(row.Detail!)).Append("</span>");
  387. sb.Append("</td>");
  388. }
  389. sb.Append("</tr>");
  390. }
  391. sb.Append("</table>");
  392. }
  393. sb.Append("</div>");
  394. // Mermaid label is wrapped in "...", so any " in our HTML must be
  395. // entity-encoded. We avoid literal " in inline styles by using
  396. // single quotes; this last pass catches anything still embedded.
  397. return sb.ToString().Replace("\"", "&quot;");
  398. }
  399. private static string EscapeHtml(string s) =>
  400. s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
  401. }