PlotDirector/PlotLine/Services/HelpMarkdownRenderer.cs
2026-06-24 10:09:07 +01:00

193 lines
6.5 KiB
C#

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 "<p class=\"muted\">This help article has no body content yet.</p>";
}
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var html = new StringBuilder();
var paragraph = new List<string>();
var listItems = new List<string>();
var inTable = false;
void FlushParagraph()
{
if (paragraph.Count == 0) return;
html.Append("<p>").Append(RenderInline(string.Join(" ", paragraph))).AppendLine("</p>");
paragraph.Clear();
}
void FlushList()
{
if (listItems.Count == 0) return;
html.AppendLine("<ul>");
foreach (var item in listItems)
{
html.Append("<li>").Append(RenderInline(item)).AppendLine("</li>");
}
html.AppendLine("</ul>");
listItems.Clear();
}
void CloseTable()
{
if (!inTable) return;
html.AppendLine("</tbody></table>");
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("<h").Append(level).Append(">")
.Append(RenderInline(text))
.Append("</h").Append(level).AppendLine(">");
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("<table class=\"table table-sm help-markdown-table\"><thead><tr>");
foreach (var cell in SplitTableCells(line))
{
html.Append("<th>").Append(RenderInline(cell)).AppendLine("</th>");
}
html.AppendLine("</tr></thead><tbody>");
inTable = true;
index++;
continue;
}
if (inTable)
{
html.AppendLine("<tr>");
foreach (var cell in SplitTableCells(line))
{
html.Append("<td>").Append(RenderInline(cell)).AppendLine("</td>");
}
html.AppendLine("</tr>");
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<string> 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 $"<img src=\"/HelpDrawer/Image?file={WebUtility.UrlEncode(url)}\" alt=\"{alt}\" class=\"help-article-image\" />";
});
encoded = LinkRegex().Replace(encoded, match =>
{
var text = match.Groups["text"].Value;
var url = RewriteHelpUrl(match.Groups["url"].Value);
return $"<a href=\"{url}\" target=\"_blank\" rel=\"noopener\">{text}</a>";
});
encoded = StrongRegex().Replace(encoded, "<strong>$1</strong>");
encoded = EmphasisRegex().Replace(encoded, "<em>$1</em>");
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(@"!\[(?<alt>[^\]]*)\]\((?<url>[^)]+)\)")]
private static partial Regex ImageRegex();
[GeneratedRegex(@"\[(?<text>[^\]]+)\]\((?<url>[^)]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
private static partial Regex StrongRegex();
[GeneratedRegex(@"\*([^*]+)\*")]
private static partial Regex EmphasisRegex();
}