File: Model\Markdown\HighlightedCodeBlockRenderer.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Resources;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
 
namespace Aspire.Dashboard.Model.Markdown;
 
public class HighlightedCodeBlockRenderer : HtmlObjectRenderer<CodeBlock>
{
    private static readonly Icon s_copyIcon = new Icons.Regular.Size16.Copy();
    private static readonly Icon s_checkmarkIcon = new Icons.Regular.Size16.Checkmark();
 
    private readonly IStringLocalizer<ControlsStrings> _loc;
 
    public HighlightedCodeBlockRenderer(IStringLocalizer<ControlsStrings> loc)
    {
        _loc = loc;
    }
 
    protected override void Write(HtmlRenderer renderer, CodeBlock obj)
    {
        // Markdown output can sometimes contain blank lines at the start and end of a code block.
        // Check for blank lines in these places and remove them before rendering to HTML.
        if (obj.Lines.Count > 0)
        {
            // Remove first line if blank line
            if (obj.Lines.Lines[0].Slice.Length == 0)
            {
                obj.Lines.RemoveAt(0);
            }
 
            // Remove last line if blank line
            if (obj.Lines.Count > 0 && obj.Lines.Lines[obj.Lines.Count - 1].Slice.Length == 0)
            {
                obj.Lines.RemoveAt(obj.Lines.Count - 1);
            }
        }
 
        // Don't render anything until code block content is available.
        // This means the complete syntax is available when rendering.
        if (obj.Lines.Count == 0 && obj.IsOpen)
        {
            return;
        }
 
        var codeAttributes = new HtmlAttributes();
 
        // If there isn't a language specified then default to generic "code"
        string title;
        if (obj is FencedCodeBlock fencedCode && fencedCode.Info is { Length: > 0 } info)
        {
            // Language is added to a CSS class name for highlightjs.
            // Fix known languages that contain invalid CSS class name characters.
            title = info.ToLower() switch
            {
                "c#" => "csharp",
                "c++" => "cpp",
                _ => info.ToLower()
            };
 
            codeAttributes.AddClass($"language-{title}");
 
            // This isn't used by highlightjs but it might be useful to see the underlying value in the HTML.
            codeAttributes.AddProperty("data-language", info);
        }
        else
        {
            title = "code";
        }
 
        // Add copy attributes to the copy button.
        var rawCode = GetRawCodeText(obj);
        var attributes = FluentUIExtensions.GetClipboardCopyAdditionalAttributes(rawCode, _loc[nameof(ControlsStrings.GridValueCopyToClipboard)], _loc[nameof(ControlsStrings.GridValueCopied)]);
        var copyButtonAttributes = new HtmlAttributes();
        copyButtonAttributes.AddClass("code-copy-button");
        copyButtonAttributes.AddProperty("id", $"code-copy-button-{obj.Span.Start}");
        foreach (var item in attributes)
        {
            copyButtonAttributes.AddProperty(item.Key, item.Value.ToString()!);
        }
 
        // Render the code block along with surrounding divs for styling, positioning, and the copy button.
        renderer.EnsureLine();
        renderer.Write("<pre>");
        renderer.Writer.Write(@"<div class=""code-block"">");
 
        renderer.Writer.Write(@"<div class=""code-title"">");
        renderer.Writer.Write(title);
        renderer.Writer.Write("</div>");
 
        renderer.Writer.Write(@"<div class=""code-buttons-anchor"">");
        renderer.Writer.Write(@"<div class=""code-buttons-hover"">");
        renderer.Writer.Write("<button");
        renderer.WriteAttributes(copyButtonAttributes);
        renderer.Writer.Write('>');
        renderer.Writer.Write(@"<div class=""copy-icon"">");
        renderer.Writer.Write(ToMarkup(s_copyIcon));
        renderer.Writer.Write("</div>");
        renderer.Writer.Write(@"<div class=""checkmark-icon"" style=""display:none;"">");
        renderer.Writer.Write(ToMarkup(s_checkmarkIcon));
        renderer.Writer.Write("</div>");
        renderer.Writer.Write("</button>");
        renderer.Writer.Write("</div>");
        renderer.Writer.Write("</div>");
 
        renderer.Write(@"<div class=""code-container"">");
        renderer.Write("<code");
        renderer.WriteAttributes(codeAttributes);
        renderer.Writer.Write('>');
        renderer.WriteLeafRawLines(obj, true, true);
        renderer.Writer.Write("</code>");
        renderer.Writer.Write("</div>");
 
        renderer.Writer.Write("</div>");
        renderer.Writer.Write("</pre>");
        renderer.EnsureLine();
    }
 
    public static string GetRawCodeText(CodeBlock codeBlock)
    {
        var sb = new StringBuilder();
 
        var slices = codeBlock.Lines.Lines;
        if (slices is not null)
        {
            for (var i = 0; i < slices.Length; i++)
            {
                ref var slice = ref slices[i].Slice;
                if (slice.Text is null)
                {
                    break;
                }
 
                sb.Append(slice.AsSpan());
                sb.AppendLine();
            }
        }
 
        return sb.ToString();
    }
 
    public static string ToMarkup(Icon icon)
    {
        var sizePx = (int)icon.Size;
        var size = $"{sizePx}px";
        return $@"<svg viewBox=""0 0 {sizePx} {sizePx}"" width=""{size}"" fill=""var(--accent-fill-rest)"" style=""width: {size};"" aria-hidden=""true"">{icon.Content}</svg>";
    }
}