using Markdig; using Markdig.Syntax; using System.Text; namespace AIStudio.Tools; public static class Markdown { public static readonly MarkdownPipeline SAFE_MARKDOWN_PIPELINE = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .DisableHtml() .Build(); public static readonly MarkdownPipeline CHAT_MARKDOWN_PIPELINE = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .UseSoftlineBreakAsHardlineBreak() .DisableHtml() .Build(); public static MudMarkdownProps DefaultConfig => new() { Heading = { OverrideTypo = typo => typo switch { Typo.h1 => Typo.h4, Typo.h2 => Typo.h5, Typo.h3 => Typo.h6, Typo.h4 => Typo.h6, Typo.h5 => Typo.h6, Typo.h6 => Typo.h6, _ => typo, }, } }; /// Escapes arbitrary text for literal display inside Markdown. public static string EscapeInlineText(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; var escaped = new StringBuilder(value.Length); foreach (var character in value) { if (character is '\r' or '\n' or '\t' || char.IsControl(character)) { escaped.Append(' '); continue; } if (character is >= '!' and <= '/' or >= ':' and <= '@' or >= '[' and <= '`' or >= '{' and <= '~') escaped.Append('\\'); escaped.Append(character); } return escaped.ToString(); } /// Closes a code fence which the text opened but never closed. /// /// An unclosed fence runs to the end of the document, so anything appended after it would be /// read as code instead of as Markdown. The chat never shows this, because it renders the answer /// and what belongs below it separately. A document is one text, and there an answer which ends /// in an open fence would swallow whatever follows it. /// /// The Markdown text to inspect. /// The text with its open fence closed, or the text itself when no fence is open. public static string CloseOpenCodeFence(string markdownText) { if (string.IsNullOrWhiteSpace(markdownText)) return markdownText; var document = Markdig.Markdown.Parse(markdownText, SAFE_MARKDOWN_PIPELINE); // Only the last fence of a text can be an open one: an open fence takes everything // after it with it, so no other block is able to follow it. if (document.Descendants().LastOrDefault() is not { ClosingFencedCharCount: 0 } openFence) return markdownText; return $"{markdownText}{Environment.NewLine}{new string(openFence.FencedChar, openFence.OpeningFencedCharCount)}"; } public static string RemoveSharedIndentation(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; return RemoveSharedIndentation(value.AsSpan()); } private static string RemoveSharedIndentation(ReadOnlySpan value) { var firstContentLineStart = -1; var lastContentLineStart = -1; var lastContentLineEnd = -1; var commonIndentation = int.MaxValue; var position = 0; while (TryGetNextLine(value, position, out var lineStart, out var currentLineEnd, out var nextPosition)) { var lineContent = value[lineStart..currentLineEnd]; if (IsWhiteSpace(lineContent)) { position = nextPosition; continue; } if (firstContentLineStart < 0) firstContentLineStart = lineStart; lastContentLineStart = lineStart; lastContentLineEnd = currentLineEnd; commonIndentation = Math.Min(commonIndentation, CountIndentation(lineContent)); position = nextPosition; } if (firstContentLineStart < 0) return string.Empty; if (commonIndentation == int.MaxValue) commonIndentation = 0; var builder = new StringBuilder(lastContentLineEnd - firstContentLineStart); var shouldAppendLineBreak = false; position = firstContentLineStart; while (TryGetNextLine(value, position, out var lineStart, out var lineEnd, out var nextPosition)) { var lineContent = value[lineStart..lineEnd]; if (shouldAppendLineBreak) builder.Append('\n'); if (IsWhiteSpace(lineContent)) shouldAppendLineBreak = true; else if (lineContent.Length > commonIndentation) { builder.Append(lineContent[commonIndentation..]); shouldAppendLineBreak = true; } else shouldAppendLineBreak = true; if (lineStart == lastContentLineStart) break; position = nextPosition; } return builder.ToString(); } private static bool IsWhiteSpace(ReadOnlySpan value) { foreach (var character in value) { if (!char.IsWhiteSpace(character)) return false; } return true; } private static int CountIndentation(ReadOnlySpan value) { var indentation = 0; while (indentation < value.Length && char.IsWhiteSpace(value[indentation])) indentation++; return indentation; } private static bool TryGetNextLine(ReadOnlySpan value, int position, out int lineStart, out int lineEnd, out int nextPosition) { if (position > value.Length) { lineStart = 0; lineEnd = 0; nextPosition = position; return false; } lineStart = position; for (var i = position; i < value.Length; i++) { if (value[i] != '\n') continue; lineEnd = i > lineStart && value[i - 1] == '\r' ? i - 1 : i; nextPosition = i + 1; return true; } lineEnd = value.Length; nextPosition = value.Length + 1; return true; } }