namespace Explorer.Application; public sealed record MarkdownDocument(IReadOnlyList Blocks, IReadOnlyList 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 Items) : MarkdownBlock; public sealed record MarkdownCode(string Language, string Text) : MarkdownBlock; public sealed record MarkdownTable(IReadOnlyList Headers, IReadOnlyList> 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(); var headings = new List(); var ids = new HashSet(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(); 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(); 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(); 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 { 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>(); while (i < lines.Length && lines[i].TrimStart().StartsWith('|')) { rows.Add(SplitRow(lines[i])); i++; } return new MarkdownTable(header, rows); } private static List 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 ids) { var id = string.IsNullOrEmpty(slug) ? "section" : slug; var n = 2; var candidate = id; while (!ids.Add(candidate)) { candidate = id + "-" + n; n++; } return candidate; } }