using System.Text; using System.Text.RegularExpressions; namespace RelayClient.Helpers; public static class MarkdownHelper { private static readonly Regex FencedCode = new(@"```([A-Za-z0-9_+#-]*)\r?\n?(.*?)```", RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Color MentionText = Color.FromArgb("#9EA8FF"); private static readonly Color MentionBg = Color.FromArgb("#2D2F5C"); private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23"); /// /// The entry point. Returns either a single Label (simple inline text) or a /// VerticalStackLayout (anything with paragraphs, code blocks, or headers). /// First pass extracts fenced code blocks (verbatim, can span multiple lines), then /// AppendTextSegment handles per-line headers and the inline parser. /// public static View Render(string markdown, double fontSize = 14) { if (string.IsNullOrEmpty(markdown)) return new Label { Text = string.Empty, FontSize = fontSize }; var stack = new VerticalStackLayout { Spacing = 2 }; var matches = FencedCode.Matches(markdown); int cursor = 0; foreach (Match m in matches) { if (m.Index > cursor) AppendTextSegment(stack, markdown[cursor..m.Index], fontSize); stack.Children.Add(CreateCodeBlock(m.Groups[1].Value.Trim(), m.Groups[2].Value.TrimEnd())); cursor = m.Index + m.Length; } if (cursor < markdown.Length) AppendTextSegment(stack, markdown[cursor..], fontSize); return stack.Children.Count == 1 ? (View)stack.Children[0] : stack; } /// /// Splits a non-code segment by newline and emits the right view per line. Headers/subtext /// get their own labels; consecutive normal lines accumulate into a paragraph buffer so /// they wrap naturally as one paragraph. /// private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize) { var paragraphBuffer = new StringBuilder(); void FlushParagraph() { if (paragraphBuffer.Length == 0) return; stack.Children.Add(CreateInlineLabel(paragraphBuffer.ToString(), fontSize)); paragraphBuffer.Clear(); } foreach (var rawLine in segment.Split('\n')) { var line = rawLine.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(line)) { FlushParagraph(); continue; } if (line.StartsWith("### ")) { FlushParagraph(); stack.Children.Add(CreateHeaderLabel(line[4..], fontSize + 3)); continue; } if (line.StartsWith("## ")) { FlushParagraph(); stack.Children.Add(CreateHeaderLabel(line[3..], fontSize + 6)); continue; } if (line.StartsWith("# ")) { FlushParagraph(); stack.Children.Add(CreateHeaderLabel(line[2..], fontSize + 10)); continue; } if (line.StartsWith("-# ")) { FlushParagraph(); stack.Children.Add(CreateSubtextLabel(line[3..], fontSize - 3)); continue; } if (paragraphBuffer.Length > 0) paragraphBuffer.Append('\n'); paragraphBuffer.Append(line); } FlushParagraph(); } /// /// Builds the dark-pane code block. If a language is specified, delegates token coloring /// to SyntaxHighlighter and prepends a small green language label (Discord-style). /// private static View CreateCodeBlock(string language, string code) { var label = new Label { FontFamily = "AnonymousProRegular", FontSize = 12, TextColor = Color.FromArgb("#D4D4D4"), LineBreakMode = LineBreakMode.WordWrap }; var spans = SyntaxHighlighter.Highlight(code, language, 12); if (spans.Count > 0) { var fs = new FormattedString(); foreach (var s in spans) fs.Spans.Add(s); label.FormattedText = fs; } else { label.Text = code; } var stack = new VerticalStackLayout { Spacing = 4 }; if (!string.IsNullOrWhiteSpace(language)) { stack.Children.Add(new Label { Text = language.ToLowerInvariant(), FontFamily = "AnonymousProRegular", FontSize = 10, TextColor = Color.FromArgb("#6A9955"), FontAttributes = FontAttributes.Bold }); } stack.Children.Add(label); return new Border { BackgroundColor = Color.FromArgb("#1E1E1E"), StrokeThickness = 0, Padding = new Thickness(10, 6), Content = stack }; } /// Bold, larger Label for # / ## / ### lines. Inline markdown still works inside (e.g. `# Hello **world**`). private static Label CreateHeaderLabel(string text, double size) { var label = new Label { FontSize = size, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.WordWrap, Margin = new Thickness(0, 4, 0, 2) }; var fs = new FormattedString(); var spoilerSpans = new List(); ParseInline(text, fs.Spans, size, spoilerSpans); if (fs.Spans.Count > 0) label.FormattedText = fs; else label.Text = text; WireSpoilerTap(label, spoilerSpans); return label; } /// Smaller, grey Label for "-#" lines (Discord calls it subtext). Inherits inline markdown. private static Label CreateSubtextLabel(string text, double size) { var label = new Label { FontSize = size, TextColor = Color.FromArgb("#8E8E93"), LineBreakMode = LineBreakMode.WordWrap }; var fs = new FormattedString(); var spoilerSpans = new List(); ParseInline(text, fs.Spans, size, spoilerSpans); if (fs.Spans.Count > 0) { foreach (var s in fs.Spans) s.TextColor ??= Color.FromArgb("#8E8E93"); label.FormattedText = fs; } else { label.Text = text; } WireSpoilerTap(label, spoilerSpans); return label; } /// Standard paragraph Label. Runs the inline parser to build a FormattedString of spans. private static Label CreateInlineLabel(string text, double fontSize) { var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap }; var fs = new FormattedString(); var spoilerSpans = new List(); ParseInline(text, fs.Spans, fontSize, spoilerSpans); if (fs.Spans.Count > 0) label.FormattedText = fs; else label.Text = text; WireSpoilerTap(label, spoilerSpans); return label; } /// /// Attaches a TapGestureRecognizer that reveals every spoiler span in the label when /// tapped once. MAUI Spans can't fire their own gesture events, so per-spoiler reveal /// would require splitting the line into separate labels — this is the pragmatic compromise. /// private static void WireSpoilerTap(Label label, List spoilerSpans) { if (spoilerSpans.Count == 0) return; var tap = new TapGestureRecognizer(); tap.Tapped += (_, _) => { foreach (var s in spoilerSpans) { s.BackgroundColor = Colors.Transparent; s.TextColor = null; // fall back to default label color } }; label.GestureRecognizers.Add(tap); } /// /// Single-pass character walk. For each markdown sigil (||, @, ~~, __, **, *, `), tries /// to find a matching closer; if found, emits a styled Span and skips past. Otherwise the /// char accumulates into a "plain" buffer that's flushed as a plain Span when the next /// sigil hits or the string ends. Spoiler spans are registered in spoilerSpans for reveal. /// private static void ParseInline(string text, IList spans, double fontSize, List spoilerSpans) { var plain = new StringBuilder(); int i = 0; void Flush() { if (plain.Length == 0) return; spans.Add(new Span { Text = plain.ToString(), FontSize = fontSize }); plain.Clear(); } while (i < text.Length) { char c = text[i]; if (c == '|' && Peek(text, i + 1) == '|') { int end = text.IndexOf("||", i + 2, StringComparison.Ordinal); if (end > i + 2) { Flush(); var span = new Span { Text = text[(i + 2)..end], FontSize = fontSize, BackgroundColor = SpoilerBg, TextColor = SpoilerBg // text invisible until revealed }; spans.Add(span); spoilerSpans.Add(span); i = end + 2; continue; } } if (c == '@' && i + 1 < text.Length && (char.IsLetter(text[i + 1]) || text[i + 1] == '_')) { int end = i + 1; while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) end++; Flush(); spans.Add(new Span { Text = text[i..end], TextColor = MentionText, BackgroundColor = MentionBg, FontAttributes = FontAttributes.Bold, FontSize = fontSize }); i = end; continue; } if (c == '~' && Peek(text, i + 1) == '~') { int end = text.IndexOf("~~", i + 2, StringComparison.Ordinal); if (end > i + 2) { Flush(); spans.Add(new Span { Text = text[(i + 2)..end], FontSize = fontSize, TextDecorations = TextDecorations.Strikethrough }); i = end + 2; continue; } } if (c == '_' && Peek(text, i + 1) == '_') { int end = text.IndexOf("__", i + 2, StringComparison.Ordinal); if (end > i + 2) { Flush(); spans.Add(new Span { Text = text[(i + 2)..end], FontSize = fontSize, TextDecorations = TextDecorations.Underline }); i = end + 2; continue; } } if (c == '*' && Peek(text, i + 1) == '*') { int end = text.IndexOf("**", i + 2, StringComparison.Ordinal); if (end > i + 2) { Flush(); spans.Add(new Span { Text = text[(i + 2)..end], FontSize = fontSize, FontAttributes = FontAttributes.Bold }); i = end + 2; continue; } } if (c == '*' && Peek(text, i + 1) != '*') { int end = FindClosingSingle(text, '*', i + 1); if (end > i + 1) { Flush(); spans.Add(new Span { Text = text[(i + 1)..end], FontSize = fontSize, FontAttributes = FontAttributes.Italic }); i = end + 1; continue; } } if (c == '`') { int end = text.IndexOf('`', i + 1); if (end > i + 1) { Flush(); spans.Add(new Span { Text = text[(i + 1)..end], FontFamily = "AnonymousProRegular", FontSize = fontSize - 1, BackgroundColor = Color.FromArgb("#2D2D2D"), TextColor = Color.FromArgb("#CE9178") }); i = end + 1; continue; } } plain.Append(c); i++; } Flush(); } /// Safe one-character lookahead. Returns '\0' past end-of-string. private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0'; /// /// Finds the next single occurrence of marker that is NOT immediately followed by /// another marker. Used to disambiguate "*italic*" from "**bold**". /// private static int FindClosingSingle(string text, char marker, int start) { for (int i = start; i < text.Length; i++) if (text[i] == marker && Peek(text, i + 1) != marker) return i; return -1; } }