using System; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Exporting; internal partial class HtmlMarkdownVisitor : MarkdownVisitor { private readonly ExportContext _context; private readonly StringBuilder _buffer; private readonly bool _isJumbo; public HtmlMarkdownVisitor(ExportContext context, StringBuilder buffer, bool isJumbo) { _context = context; _buffer = buffer; _isJumbo = isJumbo; } protected override ValueTask VisitTextAsync( TextNode text, CancellationToken cancellationToken = default) { _buffer.Append(HtmlEncode(text.Text)); return default; } protected override async ValueTask VisitFormattingAsync( FormattingNode formatting, CancellationToken cancellationToken = default) { var (openingTag, closingTag) = formatting.Kind switch { FormattingKind.Bold => ( // lang=html "", // lang=html "" ), FormattingKind.Italic => ( // lang=html "", // lang=html "" ), FormattingKind.Underline => ( // lang=html "", // lang=html "" ), FormattingKind.Strikethrough => ( // lang=html "", // lang=html "" ), FormattingKind.Spoiler => ( // lang=html """""", // lang=html """""" ), FormattingKind.Quote => ( // lang=html """
""", // lang=html """
""" ), _ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.") }; _buffer.Append(openingTag); await VisitAsync(formatting.Children, cancellationToken); _buffer.Append(closingTag); } protected override async ValueTask VisitHeadingAsync( HeadingNode heading, CancellationToken cancellationToken = default) { _buffer.Append( // lang=html $"" ); await VisitAsync(heading.Children, cancellationToken); _buffer.Append( // lang=html $"" ); } protected override async ValueTask VisitListAsync( ListNode list, CancellationToken cancellationToken = default) { _buffer.Append( // lang=html "" ); } protected override async ValueTask VisitListItemAsync( ListItemNode listItem, CancellationToken cancellationToken = default) { _buffer.Append( // lang=html "
  • " ); await VisitAsync(listItem.Children, cancellationToken); _buffer.Append( // lang=html "
  • " ); } protected override ValueTask VisitInlineCodeBlockAsync( InlineCodeBlockNode inlineCodeBlock, CancellationToken cancellationToken = default) { _buffer.Append( // lang=html $""" {HtmlEncode(inlineCodeBlock.Code)} """ ); return default; } protected override ValueTask VisitMultiLineCodeBlockAsync( MultiLineCodeBlockNode multiLineCodeBlock, CancellationToken cancellationToken = default) { var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language) ? $"language-{multiLineCodeBlock.Language}" : "nohighlight"; _buffer.Append( // lang=html $""" {HtmlEncode(multiLineCodeBlock.Code)} """ ); return default; } protected override async ValueTask VisitLinkAsync( LinkNode link, CancellationToken cancellationToken = default) { // Try to extract the message ID if the link points to a Discord message var linkedMessageId = Regex.Match( link.Url, @"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$" ).Groups[1].Value; _buffer.Append( !string.IsNullOrWhiteSpace(linkedMessageId) // lang=html ? $"""""" // lang=html : $"""""" ); await VisitAsync(link.Children, cancellationToken); _buffer.Append( // lang=html "" ); } protected override async ValueTask VisitEmojiAsync( EmojiNode emoji, CancellationToken cancellationToken = default) { var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; _buffer.Append( // lang=html $""" {emoji.Name} """ ); } protected override async ValueTask VisitMentionAsync(MentionNode mention, CancellationToken cancellationToken = default) { if (mention.Kind == MentionKind.Everyone) { _buffer.Append( // lang=html """ @everyone """ ); } else if (mention.Kind == MentionKind.Here) { _buffer.Append( // lang=html """ @here """ ); } else if (mention.Kind == MentionKind.User) { // User mentions are not always included in the message object, // which means they need to be populated on demand. // https://github.com/Tyrrrz/DiscordChatExporter/issues/304 if (mention.TargetId is not null) await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); var member = mention.TargetId?.Pipe(_context.TryGetMember); var fullName = member?.User.FullName ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown"; _buffer.Append( // lang=html $""" @{HtmlEncode(displayName)} """ ); } else if (mention.Kind == MentionKind.Channel) { var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#"; var name = channel?.Name ?? "deleted-channel"; _buffer.Append( // lang=html $""" {symbol}{HtmlEncode(name)} """ ); } else if (mention.Kind == MentionKind.Role) { var role = mention.TargetId?.Pipe(_context.TryGetRole); var name = role?.Name ?? "deleted-role"; var color = role?.Color; var style = color is not null ? $""" color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1); """ : null; _buffer.Append( // lang=html $""" @{HtmlEncode(name)} """ ); } } protected override ValueTask VisitTimestampAsync( TimestampNode timestamp, CancellationToken cancellationToken = default) { var formatted = timestamp.Instant is not null ? !string.IsNullOrWhiteSpace(timestamp.Format) ? timestamp.Instant.Value.ToLocalString(timestamp.Format) : _context.FormatDate(timestamp.Instant.Value) : "Invalid date"; var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? ""; _buffer.Append( // lang=html $""" {HtmlEncode(formatted)} """ ); return default; } } internal partial class HtmlMarkdownVisitor { private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text); public static async ValueTask FormatAsync( ExportContext context, string markdown, bool isJumboAllowed = true, CancellationToken cancellationToken = default) { var nodes = MarkdownParser.Parse(markdown); var isJumbo = isJumboAllowed && nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)); var buffer = new StringBuilder(); await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes, cancellationToken); return buffer.ToString(); } }