messages) =>
+ messages.GroupContiguous((buffer, message) =>
+ {
+ // Break group if the author changed
+ if (buffer.Last().Author.Id != message.Author.Id)
+ return false;
+
+ // Break group if last message was more than 7 minutes ago
+ if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
+ return false;
+
+ return true;
+ }).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
+
+ private string FormatMarkdown(Node node, bool isTopLevel, bool isSingle)
+ {
+ // Text node
+ if (node is TextNode textNode)
+ {
+ // Return HTML-encoded text
+ return HtmlEncode(textNode.Text);
+ }
+
+ // Formatted node
+ if (node is FormattedNode formattedNode)
+ {
+ // Recursively get inner html
+ var innerHtml = FormatMarkdown(formattedNode.Children, false);
+
+ // Bold
+ if (formattedNode.Formatting == TextFormatting.Bold)
+ return $"{innerHtml}";
+
+ // Italic
+ if (formattedNode.Formatting == TextFormatting.Italic)
+ return $"{innerHtml}";
+
+ // Underline
+ if (formattedNode.Formatting == TextFormatting.Underline)
+ return $"{innerHtml}";
+
+ // Strikethrough
+ if (formattedNode.Formatting == TextFormatting.Strikethrough)
+ return $"{innerHtml}";
+
+ // Spoiler
+ if (formattedNode.Formatting == TextFormatting.Spoiler)
+ return $"{innerHtml}";
+ }
+
+ // Inline code block node
+ if (node is InlineCodeBlockNode inlineCodeBlockNode)
+ {
+ return $"{HtmlEncode(inlineCodeBlockNode.Code)}";
+ }
+
+ // Multi-line code block node
+ if (node is MultilineCodeBlockNode multilineCodeBlockNode)
+ {
+ // Set language class for syntax highlighting
+ var languageCssClass = multilineCodeBlockNode.Language != null
+ ? "language-" + multilineCodeBlockNode.Language
+ : null;
+
+ return $"{HtmlEncode(multilineCodeBlockNode.Code)}
";
+ }
+
+ // Mention node
+ if (node is MentionNode mentionNode)
+ {
+ // Meta mention node
+ if (mentionNode.Type == MentionType.Meta)
+ {
+ return $"@{HtmlEncode(mentionNode.Id)}";
+ }
+
+ // User mention node
+ if (mentionNode.Type == MentionType.User)
+ {
+ var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
+ return $"@{HtmlEncode(user.Name)}";
+ }
+
+ // Channel mention node
+ if (mentionNode.Type == MentionType.Channel)
+ {
+ var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
+ return $"#{HtmlEncode(channel.Name)}";
+ }
+
+ // Role mention node
+ if (mentionNode.Type == MentionType.Role)
+ {
+ var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
+ return $"@{HtmlEncode(role.Name)}";
+ }
+ }
+
+ // Emoji node
+ if (node is EmojiNode emojiNode)
+ {
+ // Get emoji image URL
+ var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated);
+
+ // Emoji can be jumboable if it's the only top-level node
+ var jumboableCssClass = isTopLevel && isSingle ? "emoji--large" : null;
+
+ return $"
";
+ }
+
+ // Link node
+ if (node is LinkNode linkNode)
+ {
+ return $"{HtmlEncode(linkNode.Title)}";
+ }
+
+ // All other nodes - simply return source
+ return node.Source;
+ }
+
+ private string FormatMarkdown(IReadOnlyList nodes, bool isTopLevel)
+ {
+ var isSingle = nodes.Count == 1;
+ return nodes.Select(n => FormatMarkdown(n, isTopLevel, isSingle)).JoinToString("");
+ }
+
+ private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
+
+ public async Task RenderAsync(TextWriter writer)
+ {
+ // Create template loader
+ var loader = new TemplateLoader();
+
+ // Get template
+ var templateCode = loader.Load($"Html{_themeName}.html");
+ var template = Template.Parse(templateCode);
+
+ // Create template context
+ var context = new TemplateContext
+ {
+ TemplateLoader = loader,
+ MemberRenamer = m => m.Name,
+ MemberFilter = m => true,
+ LoopLimit = int.MaxValue,
+ StrictVariables = true
+ };
+
+ // Create template model
+ var model = new ScriptObject();
+ model.SetValue("Model", _chatLog, true);
+ model.Import(nameof(GroupMessages), new Func, IEnumerable>(GroupMessages));
+ model.Import(nameof(FormatDate), new Func(FormatDate));
+ model.Import(nameof(FormatMarkdown), new Func(FormatMarkdown));
+ context.PushGlobal(model);
+
+ // Configure output
+ context.PushOutput(new TextWriterOutput(writer));
+
+ // HACK: Render output in a separate thread
+ // (even though Scriban has async API, it still makes a lot of blocking CPU-bound calls)
+ await Task.Run(async () => await context.EvaluateAsync(template.Page));
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter.Core.Rendering/IChatLogRenderer.cs b/DiscordChatExporter.Core.Rendering/IChatLogRenderer.cs
new file mode 100644
index 00000000..8f280c6a
--- /dev/null
+++ b/DiscordChatExporter.Core.Rendering/IChatLogRenderer.cs
@@ -0,0 +1,10 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace DiscordChatExporter.Core.Rendering
+{
+ public interface IChatLogRenderer
+ {
+ Task RenderAsync(TextWriter writer);
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter.Core.Rendering/PlainTextChatLogRenderer.cs b/DiscordChatExporter.Core.Rendering/PlainTextChatLogRenderer.cs
new file mode 100644
index 00000000..31b6a3b7
--- /dev/null
+++ b/DiscordChatExporter.Core.Rendering/PlainTextChatLogRenderer.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using DiscordChatExporter.Core.Markdown;
+using DiscordChatExporter.Core.Markdown.Nodes;
+using DiscordChatExporter.Core.Models;
+using Tyrrrz.Extensions;
+
+namespace DiscordChatExporter.Core.Rendering
+{
+ public class PlainTextChatLogRenderer : IChatLogRenderer
+ {
+ private readonly ChatLog _chatLog;
+ private readonly string _dateFormat;
+
+ public PlainTextChatLogRenderer(ChatLog chatLog, string dateFormat)
+ {
+ _chatLog = chatLog;
+ _dateFormat = dateFormat;
+ }
+
+ private string FormatDate(DateTime date) => date.ToString(_dateFormat, CultureInfo.InvariantCulture);
+
+ private string FormatDateRange(DateTime? from, DateTime? to)
+ {
+ // Both 'from' and 'to'
+ if (from.HasValue && to.HasValue)
+ return $"{FormatDate(from.Value)} to {FormatDate(to.Value)}";
+
+ // Just 'from'
+ if (from.HasValue)
+ return $"after {FormatDate(from.Value)}";
+
+ // Just 'to'
+ if (to.HasValue)
+ return $"before {FormatDate(to.Value)}";
+
+ // Neither
+ return null;
+ }
+
+ private string FormatMarkdown(Node node)
+ {
+ // Formatted node
+ if (node is FormattedNode formattedNode)
+ {
+ // Recursively get inner text
+ var innerText = FormatMarkdown(formattedNode.Children);
+
+ return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
+ }
+
+ // Non-meta mention node
+ if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
+ {
+ // User mention node
+ if (mentionNode.Type == MentionType.User)
+ {
+ var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
+ return $"@{user.Name}";
+ }
+
+ // Channel mention node
+ if (mentionNode.Type == MentionType.Channel)
+ {
+ var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
+ return $"#{channel.Name}";
+ }
+
+ // Role mention node
+ if (mentionNode.Type == MentionType.Role)
+ {
+ var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
+ return $"@{role.Name}";
+ }
+ }
+
+ // Custom emoji node
+ if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
+ {
+ return $":{emojiNode.Name}:";
+ }
+
+ // All other nodes - simply return source
+ return node.Source;
+ }
+
+ private string FormatMarkdown(IEnumerable nodes) => nodes.Select(FormatMarkdown).JoinToString("");
+
+ private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
+
+ private async Task RenderMessageAsync(TextWriter writer, Message message)
+ {
+ // Timestamp and author
+ await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}");
+
+ // Content
+ await writer.WriteLineAsync(FormatMarkdown(message.Content));
+
+ // Attachments
+ foreach (var attachment in message.Attachments)
+ await writer.WriteLineAsync(attachment.Url);
+ }
+
+ public async Task RenderAsync(TextWriter writer)
+ {
+ // Metadata
+ await writer.WriteLineAsync('='.Repeat(62));
+ await writer.WriteLineAsync($"Guild: {_chatLog.Guild.Name}");
+ await writer.WriteLineAsync($"Channel: {_chatLog.Channel.Name}");
+ await writer.WriteLineAsync($"Topic: {_chatLog.Channel.Topic}");
+ await writer.WriteLineAsync($"Messages: {_chatLog.Messages.Count:N0}");
+ await writer.WriteLineAsync($"Range: {FormatDateRange(_chatLog.From, _chatLog.To)}");
+ await writer.WriteLineAsync('='.Repeat(62));
+ await writer.WriteLineAsync();
+
+ // Log
+ foreach (var message in _chatLog.Messages)
+ {
+ await RenderMessageAsync(writer, message);
+ await writer.WriteLineAsync();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Theme.css b/DiscordChatExporter.Core.Rendering/Resources/HtmlDark.css
similarity index 100%
rename from DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Theme.css
rename to DiscordChatExporter.Core.Rendering/Resources/HtmlDark.css
diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlDark.html b/DiscordChatExporter.Core.Rendering/Resources/HtmlDark.html
new file mode 100644
index 00000000..5cafcc86
--- /dev/null
+++ b/DiscordChatExporter.Core.Rendering/Resources/HtmlDark.html
@@ -0,0 +1,3 @@
+{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
+{{~ HighlightJsStyleName = "solarized-dark" ~}}
+{{~ include "HtmlShared.html" ~}}
\ No newline at end of file
diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Theme.css b/DiscordChatExporter.Core.Rendering/Resources/HtmlLight.css
similarity index 100%
rename from DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Theme.css
rename to DiscordChatExporter.Core.Rendering/Resources/HtmlLight.css
diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlLight.html b/DiscordChatExporter.Core.Rendering/Resources/HtmlLight.html
new file mode 100644
index 00000000..49c1b931
--- /dev/null
+++ b/DiscordChatExporter.Core.Rendering/Resources/HtmlLight.html
@@ -0,0 +1,3 @@
+{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
+{{~ HighlightJsStyleName = "solarized-light" ~}}
+{{~ include "HtmlShared.html" ~}}
\ No newline at end of file
diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.css b/DiscordChatExporter.Core.Rendering/Resources/HtmlShared.css
similarity index 100%
rename from DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.css
rename to DiscordChatExporter.Core.Rendering/Resources/HtmlShared.css
diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html b/DiscordChatExporter.Core.Rendering/Resources/HtmlShared.html
similarity index 99%
rename from DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html
rename to DiscordChatExporter.Core.Rendering/Resources/HtmlShared.html
index 2b173087..a2a3fc56 100644
--- a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html
+++ b/DiscordChatExporter.Core.Rendering/Resources/HtmlShared.html
@@ -9,7 +9,7 @@
{{~ # Styles ~}}