diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index 4c55d2e..b4f5323 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -25,59 +25,59 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor _isJumbo = isJumbo; } - protected override async ValueTask VisitTextAsync( + protected override ValueTask VisitTextAsync( TextNode text, CancellationToken cancellationToken = default) { _buffer.Append(HtmlEncode(text.Text)); - return await base.VisitTextAsync(text, cancellationToken); + return default; } - protected override async ValueTask VisitFormattingAsync( + protected override async ValueTask VisitFormattingAsync( FormattingNode formatting, CancellationToken cancellationToken = default) { var (openingTag, closingTag) = formatting.Kind switch { FormattingKind.Bold => ( - // language=HTML + // lang=html "", - // language=HTML + // lang=html "" ), FormattingKind.Italic => ( - // language=HTML + // lang=html "", - // language=HTML + // lang=html "" ), FormattingKind.Underline => ( - // language=HTML + // lang=html "", - // language=HTML + // lang=html "" ), FormattingKind.Strikethrough => ( - // language=HTML + // lang=html "", - // language=HTML + // lang=html "" ), FormattingKind.Spoiler => ( - // language=HTML + // lang=html """""", - // language=HTML + // lang=html """""" ), FormattingKind.Quote => ( - // language=HTML + // lang=html """
""", - // language=HTML + // lang=html """
""" ), @@ -85,13 +85,11 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor }; _buffer.Append(openingTag); - var result = await base.VisitFormattingAsync(formatting, cancellationToken); + await VisitAsync(formatting.Children, cancellationToken); _buffer.Append(closingTag); - - return result; } - protected override async ValueTask VisitHeaderAsync( + protected override async ValueTask VisitHeaderAsync( HeaderNode header, CancellationToken cancellationToken = default) { @@ -100,31 +98,63 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor $"" ); - var result = await base.VisitHeaderAsync(header, cancellationToken); + await VisitAsync(header.Children, cancellationToken); _buffer.Append( // lang=html $"" ); + } + + protected override async ValueTask VisitListAsync( + ListNode list, + CancellationToken cancellationToken = default) + { + _buffer.Append( + // lang=html + "
    " + ); + + await VisitAsync(list.Items, cancellationToken); + + _buffer.Append( + // lang=html + "
" + ); + } + + protected override async ValueTask VisitListItemAsync( + ListItemNode listItem, + CancellationToken cancellationToken = default) + { + _buffer.Append( + // lang=html + "
  • " + ); - return result; + await VisitAsync(listItem.Children, cancellationToken); + + _buffer.Append( + // lang=html + "
  • " + ); } - protected override async ValueTask VisitInlineCodeBlockAsync( + protected override ValueTask VisitInlineCodeBlockAsync( InlineCodeBlockNode inlineCodeBlock, CancellationToken cancellationToken = default) { _buffer.Append( - // language=HTML + // lang=html $""" {HtmlEncode(inlineCodeBlock.Code)} """ ); - return await base.VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken); + return default; } - protected override async ValueTask VisitMultiLineCodeBlockAsync( + protected override ValueTask VisitMultiLineCodeBlockAsync( MultiLineCodeBlockNode multiLineCodeBlock, CancellationToken cancellationToken = default) { @@ -133,16 +163,16 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor : "nohighlight"; _buffer.Append( - // language=HTML + // lang=html $""" {HtmlEncode(multiLineCodeBlock.Code)} """ ); - return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken); + return default; } - protected override async ValueTask VisitLinkAsync( + protected override async ValueTask VisitLinkAsync( LinkNode link, CancellationToken cancellationToken = default) { @@ -154,21 +184,21 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor _buffer.Append( !string.IsNullOrWhiteSpace(linkedMessageId) - // language=HTML + // lang=html ? $"""""" - // language=HTML + // lang=html : $"""""" ); - var result = await base.VisitLinkAsync(link, cancellationToken); + await VisitAsync(link.Children, cancellationToken); - // language=HTML - _buffer.Append(""); - - return result; + _buffer.Append( + // lang=html + "" + ); } - protected override async ValueTask VisitEmojiAsync( + protected override async ValueTask VisitEmojiAsync( EmojiNode emoji, CancellationToken cancellationToken = default) { @@ -176,7 +206,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; _buffer.Append( - // language=HTML + // lang=html $""" """ ); - - return await base.VisitEmojiAsync(emoji, cancellationToken); } - protected override async ValueTask VisitMentionAsync( - MentionNode mention, + protected override async ValueTask VisitMentionAsync(MentionNode mention, CancellationToken cancellationToken = default) { if (mention.Kind == MentionKind.Everyone) { _buffer.Append( - // language=HTML + // lang=html """ @everyone """ @@ -206,7 +233,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor else if (mention.Kind == MentionKind.Here) { _buffer.Append( - // language=HTML + // lang=html """ @here """ @@ -225,7 +252,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var nick = member?.Nick ?? member?.User.Name ?? "Unknown"; _buffer.Append( - // language=HTML + // lang=html $""" @{HtmlEncode(nick)} """ @@ -238,7 +265,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var name = channel?.Name ?? "deleted-channel"; _buffer.Append( - // language=HTML + // lang=html $""" {symbol}{HtmlEncode(name)} """ @@ -254,20 +281,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor ? $""" 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( - // language=HTML + // lang=html $""" @{HtmlEncode(name)} """ ); } - - return await base.VisitMentionAsync(mention, cancellationToken); } - protected override async ValueTask VisitTimestampAsync( + protected override ValueTask VisitTimestampAsync( TimestampNode timestamp, CancellationToken cancellationToken = default) { @@ -280,13 +305,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? ""; _buffer.Append( - // language=HTML + // lang=html $""" {HtmlEncode(formatted)} """ ); - return await base.VisitTimestampAsync(timestamp, cancellationToken); + return default; } } diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs index 6b4accc..3d5eb07 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs @@ -18,15 +18,15 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor _buffer = buffer; } - protected override async ValueTask VisitTextAsync( + protected override ValueTask VisitTextAsync( TextNode text, CancellationToken cancellationToken = default) { _buffer.Append(text.Text); - return await base.VisitTextAsync(text, cancellationToken); + return default; } - protected override async ValueTask VisitEmojiAsync( + protected override ValueTask VisitEmojiAsync( EmojiNode emoji, CancellationToken cancellationToken = default) { @@ -36,11 +36,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor : emoji.Name ); - return await base.VisitEmojiAsync(emoji, cancellationToken); + return default; } - protected override async ValueTask VisitMentionAsync( - MentionNode mention, + protected override async ValueTask VisitMentionAsync(MentionNode mention, CancellationToken cancellationToken = default) { if (mention.Kind == MentionKind.Everyone) @@ -82,11 +81,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor _buffer.Append($"@{name}"); } - - return await base.VisitMentionAsync(mention, cancellationToken); } - protected override async ValueTask VisitTimestampAsync( + protected override ValueTask VisitTimestampAsync( TimestampNode timestamp, CancellationToken cancellationToken = default) { @@ -98,7 +95,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor : "Invalid date" ); - return await base.VisitTimestampAsync(timestamp, cancellationToken); + return default; } } diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index ad3a947..a38145e 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -759,27 +759,21 @@ } .chatlog__markdown h1 { - margin-block: 0; - margin-top: 1rem; - margin-bottom: 0.5rem; + margin: 1rem 0 0.5rem; color: @Themed("#f2f3f5", "#060607"); font-size: 1.5rem; line-height: 1; } .chatlog__markdown h2 { - margin-block: 0; - margin-top: 1rem; - margin-bottom: 0.5rem; + margin: 1rem 0 0.5rem; color: @Themed("#f2f3f5", "#060607"); font-size: 1.25rem; line-height: 1; } .chatlog__markdown h3 { - margin-block: 0; - margin-top: 1rem; - margin-bottom: 0.5rem; + margin: 1rem 0 0.5rem; color: @Themed("#f2f3f5", "#060607"); font-size: 1rem; line-height: 1; @@ -789,6 +783,11 @@ margin-top: 0.5rem; } + .chatlog__markdown ul, ol { + margin: 0 0 0 1rem; + padding: 0; + } + .chatlog__markdown-preserve { white-space: pre-wrap; } diff --git a/DiscordChatExporter.Core/Markdown/ListItemNode.cs b/DiscordChatExporter.Core/Markdown/ListItemNode.cs new file mode 100644 index 0000000..5d644ec --- /dev/null +++ b/DiscordChatExporter.Core/Markdown/ListItemNode.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Markdown; + +internal record ListItemNode(IReadOnlyList Children) : MarkdownNode, IContainerNode; \ No newline at end of file diff --git a/DiscordChatExporter.Core/Markdown/ListNode.cs b/DiscordChatExporter.Core/Markdown/ListNode.cs new file mode 100644 index 0000000..444ad1e --- /dev/null +++ b/DiscordChatExporter.Core/Markdown/ListNode.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Markdown; + +internal record ListNode(IReadOnlyList Items) : MarkdownNode; \ No newline at end of file diff --git a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs index c7f7a56..f79a262 100644 --- a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs +++ b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs @@ -24,13 +24,13 @@ internal static partial class MarkdownParser /* Formatting */ private static readonly IMatcher BoldFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest double asterisk not followed by an asterisk. + // There must be exactly two closing asterisks. new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher ItalicFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest single asterisk not preceded or followed by an asterisk. + // There must be exactly one closing asterisk. // Opening asterisk must not be followed by whitespace. // Closing asterisk must not be preceded by whitespace. new Regex(@"\*(?!\s)(.+?)(? ItalicBoldFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest triple asterisk not followed by an asterisk. + // There must be exactly three closing asterisks. new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)) ); private static readonly IMatcher ItalicAltFormattingNodeMatcher = new RegexMatcher( - // Capture any character except underscore until an underscore. // Closing underscore must not be followed by a word character. - new Regex(@"_([^_]+)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline), + new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher UnderlineFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest double underscore not followed by an underscore. + // There must be exactly two closing underscores. new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher ItalicUnderlineFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest triple underscore not followed by an underscore. + // There must be exactly three closing underscores. new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode( FormattingKind.Italic, @@ -67,68 +66,61 @@ internal static partial class MarkdownParser ); private static readonly IMatcher StrikethroughFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest double tilde. new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher SpoilerFormattingNodeMatcher = new RegexMatcher( - // Capture any character until the earliest double pipe. new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher SingleLineQuoteNodeMatcher = new RegexMatcher( - // Capture any character until the end of the line. - // Opening 'greater than' character must be followed by whitespace. - // Consume the newline character so that it's not included in the content. + // Include the linebreak in the content so that the lines are preserved in quotes. new Regex(@"^>\s(.+\n?)", DefaultRegexOptions), (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) ); private static readonly IMatcher RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher( - // Repeatedly capture any character until the end of the line. - // Consume the newline character so that it's not included in the content. + // Include the linebreaks in the content, so that the lines are preserved in quotes. new Regex(@"(?:^>\s(.+\n?)){2,}", DefaultRegexOptions), - (_, m) => new FormattingNode( + (s, m) => new FormattingNode( FormattingKind.Quote, - Parse( - // Combine all captures into a single string - string.Concat(m.Groups[1].Captures.Select(c => c.Value)) - ) + m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray() ) ); private static readonly IMatcher MultiLineQuoteNodeMatcher = new RegexMatcher( - // Capture any character until the end of the input. - // Opening 'greater than' characters must be followed by whitespace. new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline), (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) ); - /* Headers */ - private static readonly IMatcher HeaderNodeMatcher = new RegexMatcher( - // Capture any character until the end of the line. - // Opening 'hash' character(s) must be followed by whitespace. - // Consume the newline character so that it's not included in the content. - new Regex(@"^(\#{1,3})\s(.+\n?)", DefaultRegexOptions), + // Consume the linebreak so that it's not attached to following nodes. + new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions), (s, m) => new HeaderNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2]))) ); + private static readonly IMatcher ListNodeMatcher = new RegexMatcher( + // Can be preceded by whitespace, which specifies the list's nesting level. + // Following lines that start with (level+1) whitespace are considered part of the list item. + // Consume the linebreak so that it's not attached to following nodes. + new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions), + (s, m) => new ListNode( + m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray() + ) + ); + /* Code blocks */ private static readonly IMatcher InlineCodeBlockNodeMatcher = new RegexMatcher( - // Capture any character except backtick until a backtick. - // Blank lines at the beginning and at the end of content are trimmed. - // There can be either one or two backticks, but equal number on both sides. + // One or two backticks are allowed, but they must match on both sides. new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline), - (_, m) => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n')) + (_, m) => new InlineCodeBlockNode(m.Groups[2].Value) ); private static readonly IMatcher MultiLineCodeBlockNodeMatcher = new RegexMatcher( - // Capture language identifier and then any character until the earliest triple backtick. - // Language identifier is one word immediately after opening backticks, followed immediately by newline. + // Language identifier is one word immediately after opening backticks, followed immediately by a linebreak. // Blank lines at the beginning and at the end of content are trimmed. new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), (_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) @@ -215,7 +207,7 @@ internal static partial class MarkdownParser ); private static readonly IMatcher CodedStandardEmojiNodeMatcher = new RegexMatcher( - // Capture :thinking: for known emoji codes + // Capture :thinking: new Regex(@":([\w_]+):", DefaultRegexOptions), (_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n)) ); @@ -233,8 +225,8 @@ internal static partial class MarkdownParser /* Links */ private static readonly IMatcher AutoLinkNodeMatcher = new RegexMatcher( - // Capture any non-whitespace character after http:// or https:// - // until the last punctuation character or whitespace + // Any non-whitespace character after http:// or https:// + // until the last punctuation character or whitespace. new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions), (_, m) => new LinkNode(m.Groups[1].Value) ); @@ -318,8 +310,7 @@ internal static partial class MarkdownParser } ); - // Combine all matchers into one. - // Matchers that have similar patterns are ordered from most specific to least specific. + // Matchers that have similar patterns are ordered from most specific to least specific private static readonly IMatcher NodeMatcher = new AggregateMatcher( // Escaped text ShrugTextNodeMatcher, @@ -339,9 +330,8 @@ internal static partial class MarkdownParser MultiLineQuoteNodeMatcher, RepeatedSingleLineQuoteNodeMatcher, SingleLineQuoteNodeMatcher, - - // Headers HeaderNodeMatcher, + ListNodeMatcher, // Code blocks MultiLineCodeBlockNodeMatcher, diff --git a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs index de2fb3f..f0bfee7 100644 --- a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs @@ -7,93 +7,127 @@ namespace DiscordChatExporter.Core.Markdown.Parsing; internal abstract class MarkdownVisitor { - protected virtual ValueTask VisitTextAsync( + protected virtual ValueTask VisitTextAsync( TextNode text, - CancellationToken cancellationToken = default) => - new(text); + CancellationToken cancellationToken = default) => default; - protected virtual async ValueTask VisitFormattingAsync( + protected virtual async ValueTask VisitFormattingAsync( FormattingNode formatting, - CancellationToken cancellationToken = default) - { + CancellationToken cancellationToken = default) => await VisitAsync(formatting.Children, cancellationToken); - return formatting; - } - protected virtual async ValueTask VisitHeaderAsync( + protected virtual async ValueTask VisitHeaderAsync( HeaderNode header, - CancellationToken cancellationToken = default) - { + CancellationToken cancellationToken = default) => await VisitAsync(header.Children, cancellationToken); - return header; - } - protected virtual ValueTask VisitInlineCodeBlockAsync( - InlineCodeBlockNode inlineCodeBlock, + protected virtual async ValueTask VisitListAsync( + ListNode list, CancellationToken cancellationToken = default) => - new(inlineCodeBlock); + await VisitAsync(list.Items, cancellationToken); - protected virtual ValueTask VisitMultiLineCodeBlockAsync( - MultiLineCodeBlockNode multiLineCodeBlock, + protected virtual async ValueTask VisitListItemAsync( + ListItemNode listItem, CancellationToken cancellationToken = default) => - new(multiLineCodeBlock); + await VisitAsync(listItem.Children, cancellationToken); + + protected virtual ValueTask VisitInlineCodeBlockAsync( + InlineCodeBlockNode inlineCodeBlock, + CancellationToken cancellationToken = default) => default; + + protected virtual ValueTask VisitMultiLineCodeBlockAsync( + MultiLineCodeBlockNode multiLineCodeBlock, + CancellationToken cancellationToken = default) => default; - protected virtual async ValueTask VisitLinkAsync( + protected virtual async ValueTask VisitLinkAsync( LinkNode link, - CancellationToken cancellationToken = default) - { + CancellationToken cancellationToken = default) => await VisitAsync(link.Children, cancellationToken); - return link; - } - protected virtual ValueTask VisitEmojiAsync( + protected virtual ValueTask VisitEmojiAsync( EmojiNode emoji, - CancellationToken cancellationToken = default) => - new(emoji); + CancellationToken cancellationToken = default) => default; - protected virtual ValueTask VisitMentionAsync( + protected virtual ValueTask VisitMentionAsync( MentionNode mention, - CancellationToken cancellationToken = default) => - new(mention); + CancellationToken cancellationToken = default) => default; - protected virtual ValueTask VisitTimestampAsync( + protected virtual ValueTask VisitTimestampAsync( TimestampNode timestamp, - CancellationToken cancellationToken = default) => - new(timestamp); + CancellationToken cancellationToken = default) => default; - public async ValueTask VisitAsync( + public async ValueTask VisitAsync( MarkdownNode node, - CancellationToken cancellationToken = default) => node switch + CancellationToken cancellationToken = default) + { + if (node is TextNode text) { - TextNode text => - await VisitTextAsync(text, cancellationToken), + await VisitTextAsync(text, cancellationToken); + return; + } - FormattingNode formatting => - await VisitFormattingAsync(formatting, cancellationToken), + if (node is FormattingNode formatting) + { + await VisitFormattingAsync(formatting, cancellationToken); + return; + } - HeaderNode header => - await VisitHeaderAsync(header, cancellationToken), + if (node is HeaderNode header) + { + await VisitHeaderAsync(header, cancellationToken); + return; + } - InlineCodeBlockNode inlineCodeBlock => - await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken), + if (node is ListNode list) + { + await VisitListAsync(list, cancellationToken); + return; + } - MultiLineCodeBlockNode multiLineCodeBlock => - await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken), + if (node is ListItemNode listItem) + { + await VisitListItemAsync(listItem, cancellationToken); + return; + } - LinkNode link => - await VisitLinkAsync(link, cancellationToken), + if (node is InlineCodeBlockNode inlineCodeBlock) + { + await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken); + return; + } - EmojiNode emoji => - await VisitEmojiAsync(emoji, cancellationToken), + if (node is MultiLineCodeBlockNode multiLineCodeBlock) + { + await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken); + return; + } - MentionNode mention => - await VisitMentionAsync(mention, cancellationToken), + if (node is LinkNode link) + { + await VisitLinkAsync(link, cancellationToken); + return; + } - TimestampNode timestamp => - await VisitTimestampAsync(timestamp, cancellationToken), + if (node is EmojiNode emoji) + { + await VisitEmojiAsync(emoji, cancellationToken); + return; + } - _ => throw new ArgumentOutOfRangeException(nameof(node)) - }; + if (node is MentionNode mention) + { + await VisitMentionAsync(mention, cancellationToken); + return; + } + + if (node is TimestampNode timestamp) + { + await VisitTimestampAsync(timestamp, cancellationToken); + return; + } + + throw new ArgumentOutOfRangeException(nameof(node)); + } public async ValueTask VisitAsync( IEnumerable nodes,