259 lines
7.5 KiB
C#
259 lines
7.5 KiB
C#
namespace Explorer.Application;
|
|
|
|
public sealed record MarkdownDocument(IReadOnlyList<MarkdownBlock> Blocks, IReadOnlyList<MarkdownHeading> Headings);
|
|
|
|
public abstract record MarkdownBlock;
|
|
|
|
public sealed record MarkdownHeading(int Level, string Text, string Id) : MarkdownBlock;
|
|
|
|
public sealed record MarkdownParagraph(string Text) : MarkdownBlock;
|
|
|
|
public sealed record MarkdownList(bool Ordered, IReadOnlyList<string> Items) : MarkdownBlock;
|
|
|
|
public sealed record MarkdownCode(string Language, string Text) : MarkdownBlock;
|
|
|
|
public sealed record MarkdownTable(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows) : MarkdownBlock;
|
|
|
|
public sealed record MarkdownRule : MarkdownBlock;
|
|
|
|
public sealed record MarkdownQuote(string Text) : MarkdownBlock;
|
|
|
|
public static class MarkdownParser
|
|
{
|
|
public static MarkdownDocument Parse(string text)
|
|
{
|
|
var lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
|
var blocks = new List<MarkdownBlock>();
|
|
var headings = new List<MarkdownHeading>();
|
|
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var i = 0;
|
|
while (i < lines.Length)
|
|
{
|
|
var raw = lines[i];
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (raw.StartsWith("```", StringComparison.Ordinal))
|
|
{
|
|
var lang = raw[3..].Trim();
|
|
i++;
|
|
var body = new List<string>();
|
|
while (i < lines.Length && !lines[i].StartsWith("```", StringComparison.Ordinal))
|
|
{
|
|
body.Add(lines[i]);
|
|
i++;
|
|
}
|
|
|
|
if (i < lines.Length)
|
|
{
|
|
i++;
|
|
}
|
|
|
|
blocks.Add(new MarkdownCode(lang, string.Join("\n", body)));
|
|
continue;
|
|
}
|
|
|
|
if (IsRule(raw))
|
|
{
|
|
blocks.Add(new MarkdownRule());
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
var heading = ParseHeading(raw);
|
|
if (heading is not null)
|
|
{
|
|
var id = UniqueId(Slug(heading.Text), ids);
|
|
var block = heading with { Id = id };
|
|
blocks.Add(block);
|
|
if (block.Level is 2 or 3)
|
|
{
|
|
headings.Add(block);
|
|
}
|
|
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (raw.TrimStart().StartsWith('|'))
|
|
{
|
|
var table = ReadTable(lines, ref i);
|
|
if (table is not null)
|
|
{
|
|
blocks.Add(table);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (LooksLikeList(raw, out var ordered))
|
|
{
|
|
var items = new List<string>();
|
|
while (i < lines.Length && LooksLikeList(lines[i], out var next) && next == ordered)
|
|
{
|
|
items.Add(ListText(lines[i]));
|
|
i++;
|
|
}
|
|
|
|
blocks.Add(new MarkdownList(ordered, items));
|
|
continue;
|
|
}
|
|
|
|
if (raw.StartsWith("> ", StringComparison.Ordinal) || raw == ">")
|
|
{
|
|
var quote = new List<string>();
|
|
while (i < lines.Length && (lines[i].StartsWith("> ", StringComparison.Ordinal) || lines[i] == ">"))
|
|
{
|
|
quote.Add(lines[i].StartsWith("> ", StringComparison.Ordinal) ? lines[i][2..] : "");
|
|
i++;
|
|
}
|
|
|
|
blocks.Add(new MarkdownQuote(string.Join(" ", quote)));
|
|
continue;
|
|
}
|
|
|
|
var para = new List<string> { raw.Trim() };
|
|
i++;
|
|
while (i < lines.Length
|
|
&& !string.IsNullOrWhiteSpace(lines[i])
|
|
&& ParseHeading(lines[i]) is null
|
|
&& !IsRule(lines[i])
|
|
&& !lines[i].StartsWith("```", StringComparison.Ordinal)
|
|
&& !LooksLikeList(lines[i], out _)
|
|
&& !lines[i].TrimStart().StartsWith('|'))
|
|
{
|
|
para.Add(lines[i].Trim());
|
|
i++;
|
|
}
|
|
|
|
blocks.Add(new MarkdownParagraph(string.Join(" ", para)));
|
|
}
|
|
|
|
return new MarkdownDocument(blocks, headings);
|
|
}
|
|
|
|
private static MarkdownHeading? ParseHeading(string line)
|
|
{
|
|
if (!line.StartsWith('#') || line.Length < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var level = 0;
|
|
while (level < line.Length && line[level] == '#' && level < 6)
|
|
{
|
|
level++;
|
|
}
|
|
|
|
if (level == 0 || level >= line.Length || line[level] != ' ')
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new MarkdownHeading(level, line[(level + 1)..].Trim(), "");
|
|
}
|
|
|
|
private static MarkdownTable? ReadTable(string[] lines, ref int i)
|
|
{
|
|
var header = SplitRow(lines[i]);
|
|
if (header.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
i++;
|
|
if (i >= lines.Length || !IsAlignmentRow(lines[i]))
|
|
{
|
|
return new MarkdownTable(header, []);
|
|
}
|
|
|
|
i++;
|
|
var rows = new List<IReadOnlyList<string>>();
|
|
while (i < lines.Length && lines[i].TrimStart().StartsWith('|'))
|
|
{
|
|
rows.Add(SplitRow(lines[i]));
|
|
i++;
|
|
}
|
|
|
|
return new MarkdownTable(header, rows);
|
|
}
|
|
|
|
private static List<string> SplitRow(string line)
|
|
{
|
|
var trimmed = line.Trim();
|
|
if (trimmed.StartsWith('|'))
|
|
{
|
|
trimmed = trimmed[1..];
|
|
}
|
|
|
|
if (trimmed.EndsWith('|'))
|
|
{
|
|
trimmed = trimmed[..^1];
|
|
}
|
|
|
|
return trimmed.Split('|').Select(c => c.Trim()).ToList();
|
|
}
|
|
|
|
private static bool IsAlignmentRow(string line)
|
|
=> line.Contains('|') && line.All(c => c is '|' or ':' or '-' or ' ' or '\t');
|
|
|
|
private static bool IsRule(string line)
|
|
{
|
|
var t = line.Trim();
|
|
return t is "---" or "***" or "___" || (t.Length >= 3 && t.All(c => c == '-' || c == '*' || c == '_'));
|
|
}
|
|
|
|
private static bool LooksLikeList(string line, out bool ordered)
|
|
{
|
|
ordered = false;
|
|
var t = line.TrimStart();
|
|
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var dot = t.IndexOf(". ", StringComparison.Ordinal);
|
|
if (dot > 0 && t[..dot].All(char.IsDigit))
|
|
{
|
|
ordered = true;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string ListText(string line)
|
|
{
|
|
var t = line.TrimStart();
|
|
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
|
|
{
|
|
return t[2..].Trim();
|
|
}
|
|
|
|
var dot = t.IndexOf(". ", StringComparison.Ordinal);
|
|
return dot > 0 ? t[(dot + 2)..].Trim() : t;
|
|
}
|
|
|
|
private static string Slug(string text)
|
|
{
|
|
var chars = text.Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-').ToArray();
|
|
return new string(chars).Trim().Replace(' ', '-').ToLowerInvariant();
|
|
}
|
|
|
|
private static string UniqueId(string slug, HashSet<string> ids)
|
|
{
|
|
var id = string.IsNullOrEmpty(slug) ? "section" : slug;
|
|
var n = 2;
|
|
var candidate = id;
|
|
while (!ids.Add(candidate))
|
|
{
|
|
candidate = id + "-" + n;
|
|
n++;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
}
|