AI-Studio/app/MindWork AI Studio/Tools/Markdown.cs

204 lines
6.4 KiB
C#
Raw Normal View History

2026-02-26 07:51:22 +00:00
using Markdig;
using Markdig.Syntax;
using System.Text;
2026-02-26 07:51:22 +00:00
2024-05-25 20:18:49 +00:00
namespace AIStudio.Tools;
public static class Markdown
{
2026-02-26 07:51:22 +00:00
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();
2025-08-10 16:32:38 +00:00
public static MudMarkdownProps DefaultConfig => new()
2024-05-25 20:18:49 +00:00
{
2025-08-10 16:32:38 +00:00
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,
2024-05-25 20:18:49 +00:00
2025-08-10 16:32:38 +00:00
_ => typo,
},
}
2024-05-25 20:18:49 +00:00
};
/// <summary>Escapes arbitrary text for literal display inside Markdown.</summary>
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();
}
/// <summary>Closes a code fence which the text opened but never closed.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="markdownText">The Markdown text to inspect.</param>
/// <returns>The text with its open fence closed, or the text itself when no fence is open.</returns>
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<FencedCodeBlock>().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<char> 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<char> value)
{
foreach (var character in value)
{
if (!char.IsWhiteSpace(character))
return false;
}
return true;
}
private static int CountIndentation(ReadOnlySpan<char> value)
{
var indentation = 0;
while (indentation < value.Length && char.IsWhiteSpace(value[indentation]))
indentation++;
return indentation;
}
private static bool TryGetNextLine(ReadOnlySpan<char> 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;
}
2024-05-25 20:18:49 +00:00
}