using System.Net;
using System.Text;
using System.Text.RegularExpressions;
namespace PlotLine.Services;
public interface IHelpMarkdownRenderer
{
string Render(string markdown);
}
public sealed partial class HelpMarkdownRenderer(IHelpService help) : IHelpMarkdownRenderer
{
public string Render(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return "
This help article has no body content yet.
";
}
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var html = new StringBuilder();
var paragraph = new List();
var listItems = new List();
var inTable = false;
void FlushParagraph()
{
if (paragraph.Count == 0) return;
html.Append("").Append(RenderInline(string.Join(" ", paragraph))).AppendLine("
");
paragraph.Clear();
}
void FlushList()
{
if (listItems.Count == 0) return;
html.AppendLine("");
foreach (var item in listItems)
{
html.Append("- ").Append(RenderInline(item)).AppendLine("
");
}
html.AppendLine("
");
listItems.Clear();
}
void CloseTable()
{
if (!inTable) return;
html.AppendLine("");
inTable = false;
}
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index].Trim();
if (string.IsNullOrWhiteSpace(line))
{
FlushParagraph();
FlushList();
CloseTable();
continue;
}
if (line.StartsWith("#", StringComparison.Ordinal))
{
FlushParagraph();
FlushList();
CloseTable();
var level = Math.Clamp(line.TakeWhile(ch => ch == '#').Count(), 1, 4);
var text = line[level..].Trim();
html.Append("")
.Append(RenderInline(text))
.Append("");
continue;
}
if (line.StartsWith("- ", StringComparison.Ordinal) || line.StartsWith("* ", StringComparison.Ordinal))
{
FlushParagraph();
CloseTable();
listItems.Add(line[2..].Trim());
continue;
}
if (LooksLikeTableRow(line))
{
FlushParagraph();
FlushList();
if (index + 1 < lines.Length && LooksLikeTableSeparator(lines[index + 1].Trim()))
{
if (inTable) CloseTable();
html.AppendLine("");
foreach (var cell in SplitTableCells(line))
{
html.Append("| ").Append(RenderInline(cell)).AppendLine(" | ");
}
html.AppendLine("
");
inTable = true;
index++;
continue;
}
if (inTable)
{
html.AppendLine("");
foreach (var cell in SplitTableCells(line))
{
html.Append("| ").Append(RenderInline(cell)).AppendLine(" | ");
}
html.AppendLine("
");
continue;
}
}
paragraph.Add(line);
}
FlushParagraph();
FlushList();
CloseTable();
return html.ToString();
}
private static bool LooksLikeTableRow(string line) => line.Contains('|');
private static bool LooksLikeTableSeparator(string line) =>
line.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.All(cell => cell.Length > 0 && cell.All(ch => ch is '-' or ':'));
private static IReadOnlyList SplitTableCells(string line) =>
line.Trim('|').Split('|', StringSplitOptions.TrimEntries).ToList();
private string RenderInline(string value)
{
var encoded = WebUtility.HtmlEncode(value);
encoded = ImageRegex().Replace(encoded, match =>
{
var alt = match.Groups["alt"].Value;
var url = match.Groups["url"].Value;
return $"
";
});
encoded = LinkRegex().Replace(encoded, match =>
{
var text = match.Groups["text"].Value;
var url = RewriteHelpUrl(match.Groups["url"].Value);
return $"{text}";
});
encoded = StrongRegex().Replace(encoded, "$1");
encoded = EmphasisRegex().Replace(encoded, "$1");
return encoded;
}
private string RewriteHelpUrl(string url)
{
var decodedUrl = WebUtility.HtmlDecode(url);
const string articlePrefix = "/Help/Article/";
if (decodedUrl.StartsWith(articlePrefix, StringComparison.OrdinalIgnoreCase))
{
var key = Uri.UnescapeDataString(decodedUrl[articlePrefix.Length..]);
var article = help.GetByContextKey(key);
if (article is not null)
{
return article.Url;
}
}
const string categoryPrefix = "/Help/Category/";
if (decodedUrl.StartsWith(categoryPrefix, StringComparison.OrdinalIgnoreCase))
{
var category = Uri.UnescapeDataString(decodedUrl[categoryPrefix.Length..]);
var categoryName = help.GetByCategory(category).FirstOrDefault()?.Category;
if (!string.IsNullOrWhiteSpace(categoryName))
{
return $"/help/{HelpService.Slugify(categoryName)}";
}
}
return url;
}
[GeneratedRegex(@"!\[(?[^\]]*)\]\((?[^)]+)\)")]
private static partial Regex ImageRegex();
[GeneratedRegex(@"\[(?[^\]]+)\]\((?[^)]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
private static partial Regex StrongRegex();
[GeneratedRegex(@"\*([^*]+)\*")]
private static partial Regex EmphasisRegex();
}