namespace MeetingAssistant.Workflow; internal enum WorkflowRulesEditorMarkdownInlineStyle { Normal, Italic, Bold, BoldItalic, Strikethrough, Link, Image, Code } internal abstract record WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownParagraph( IReadOnlyList Inlines) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownHeading( int Level, IReadOnlyList Inlines) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownBlockQuote( IReadOnlyList Inlines) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownImage( string AltText, string Target) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownTaskList( IReadOnlyList Items) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownTaskItem( bool IsChecked, IReadOnlyList Inlines); internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownTable( IReadOnlyList Header, IReadOnlyList> Rows) : WorkflowRulesEditorMarkdownBlock; internal sealed record WorkflowRulesEditorMarkdownInline( string Text, WorkflowRulesEditorMarkdownInlineStyle Style, string? LinkTarget = null); internal static class WorkflowRulesEditorMarkdown { public static IReadOnlyList Parse(string text) { if (string.IsNullOrEmpty(text)) { return []; } var blocks = new List(); var paragraph = new List(); var code = new List(); var inCodeBlock = false; var lines = ReadLines(text).ToArray(); for (var index = 0; index < lines.Length; index++) { var line = lines[index]; if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) { if (inCodeBlock) { blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code))); code.Clear(); inCodeBlock = false; } else { FlushParagraph(blocks, paragraph); inCodeBlock = true; } continue; } if (inCodeBlock) { code.Add(line); continue; } if (TryParseStandaloneImage(line, out var image)) { FlushParagraph(blocks, paragraph); blocks.Add(image); continue; } if (TryParseHeading(line, out var heading)) { FlushParagraph(blocks, paragraph); blocks.Add(heading); continue; } if (IsBlockQuote(line)) { FlushParagraph(blocks, paragraph); var quote = new List(); while (index < lines.Length && IsBlockQuote(lines[index])) { quote.Add(lines[index].TrimStart()[1..].TrimStart()); index++; } blocks.Add(new WorkflowRulesEditorMarkdownBlockQuote(ParseInline(string.Join('\n', quote)))); index--; continue; } if (TryParseTaskLine(line, out _)) { FlushParagraph(blocks, paragraph); var tasks = new List(); while (index < lines.Length && TryParseTaskLine(lines[index], out var task)) { tasks.Add(task); index++; } blocks.Add(new WorkflowRulesEditorMarkdownTaskList(tasks)); index--; continue; } if (IsTableStart(lines, index)) { FlushParagraph(blocks, paragraph); var header = ParseTableRow(lines[index]); index += 2; var rows = new List>(); while (index < lines.Length && IsTableRow(lines[index])) { rows.Add(ParseTableRow(lines[index])); index++; } blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows)); index--; continue; } if (string.IsNullOrWhiteSpace(line)) { FlushParagraph(blocks, paragraph); } else { paragraph.Add(line); } } if (inCodeBlock) { blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code))); } FlushParagraph(blocks, paragraph); return blocks; } public static IReadOnlyList ParseInline(string text) { var result = new List(); var index = 0; while (index < text.Length) { var next = FindNextMarker(text, index); if (next < 0) { AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal); break; } if (next > index) { AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal); } if (text[next] == '`') { var end = text.IndexOf('`', next + 1); if (end > next) { AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code); index = end + 1; continue; } } else if (text.AsSpan(next).StartsWith("![", StringComparison.Ordinal)) { if (TryParseImage(text, next, out var altText, out var target, out var end)) { AddInline( result, altText, WorkflowRulesEditorMarkdownInlineStyle.Image, target); index = end + 1; continue; } } else if (text[next] == '[') { var endLabel = text.IndexOf(']', next + 1); if (endLabel > next && endLabel + 1 < text.Length && text[endLabel + 1] == '(') { var endTarget = text.IndexOf(')', endLabel + 2); if (endTarget > endLabel + 2) { AddInline( result, text[(next + 1)..endLabel], WorkflowRulesEditorMarkdownInlineStyle.Link, text[(endLabel + 2)..endTarget]); index = endTarget + 1; continue; } } } else if (text[next] == '<') { if (TryParseAutolink(text, next, out var label, out var target, out var end)) { AddInline( result, label, WorkflowRulesEditorMarkdownInlineStyle.Link, target); index = end + 1; continue; } } else if (text.AsSpan(next).StartsWith("***", StringComparison.Ordinal)) { var end = text.IndexOf("***", next + 3, StringComparison.Ordinal); if (end > next) { AddInline(result, text[(next + 3)..end], WorkflowRulesEditorMarkdownInlineStyle.BoldItalic); index = end + 3; continue; } } else if (text.AsSpan(next).StartsWith("~~", StringComparison.Ordinal)) { var end = text.IndexOf("~~", next + 2, StringComparison.Ordinal); if (end > next) { AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Strikethrough); index = end + 2; continue; } } else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal)) { var end = text.IndexOf("**", next + 2, StringComparison.Ordinal); if (end > next) { AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold); index = end + 2; continue; } } else if (text[next] == '*') { var end = text.IndexOf('*', next + 1); if (end > next) { AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic); index = end + 1; continue; } } AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal); index = next + 1; } return result; } private static void FlushParagraph( List blocks, List paragraph) { if (paragraph.Count == 0) { return; } blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph)))); paragraph.Clear(); } private static bool TryParseHeading(string line, out WorkflowRulesEditorMarkdownHeading heading) { heading = null!; var trimmed = line.TrimStart(); var level = trimmed.StartsWith("# ", StringComparison.Ordinal) ? 1 : trimmed.StartsWith("## ", StringComparison.Ordinal) ? 2 : 0; if (level == 0) { return false; } heading = new WorkflowRulesEditorMarkdownHeading(level, ParseInline(trimmed)); return true; } private static bool IsBlockQuote(string line) { return line.TrimStart().StartsWith('>'); } private static bool TryParseTaskLine(string line, out WorkflowRulesEditorMarkdownTaskItem task) { task = null!; var trimmedStart = line.TrimStart(); if (trimmedStart.StartsWith("- [x] ", StringComparison.OrdinalIgnoreCase)) { task = new WorkflowRulesEditorMarkdownTaskItem(true, ParseInline(trimmedStart[6..])); return true; } if (trimmedStart.StartsWith("- [ ] ", StringComparison.Ordinal)) { task = new WorkflowRulesEditorMarkdownTaskItem(false, ParseInline(trimmedStart[6..])); return true; } return false; } private static bool TryParseStandaloneImage( string line, out WorkflowRulesEditorMarkdownImage image) { image = null!; var trimmed = line.Trim(); if (!TryParseImage(trimmed, 0, out var altText, out var target, out var end) || end != trimmed.Length - 1) { return false; } image = new WorkflowRulesEditorMarkdownImage(altText, target); return true; } private static bool TryParseImage( string text, int start, out string altText, out string target, out int end) { altText = ""; target = ""; end = -1; if (!text.AsSpan(start).StartsWith("![", StringComparison.Ordinal)) { return false; } var endLabel = text.IndexOf(']', start + 2); if (endLabel <= start || endLabel + 1 >= text.Length || text[endLabel + 1] != '(') { return false; } var endTarget = text.IndexOf(')', endLabel + 2); if (endTarget <= endLabel + 2) { return false; } altText = text[(start + 2)..endLabel]; target = text[(endLabel + 2)..endTarget]; end = endTarget; return true; } private static bool TryParseAutolink( string text, int start, out string label, out string target, out int end) { label = ""; target = ""; end = text.IndexOf('>', start + 1); if (end <= start + 1) { return false; } label = text[(start + 1)..end]; if (label.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || label.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || label.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) { target = label; return true; } if (IsEmailAddress(label)) { target = "mailto:" + label; return true; } return false; } private static bool IsEmailAddress(string text) { var at = text.IndexOf('@'); return at > 0 && at < text.Length - 1 && text.IndexOf('.', at + 1) > at + 1 && !text.Any(char.IsWhiteSpace); } private static bool IsTableStart(IReadOnlyList lines, int index) { return index + 1 < lines.Count && IsTableRow(lines[index]) && IsTableSeparator(lines[index + 1]); } private static bool IsTableRow(string line) { return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal); } private static bool IsTableSeparator(string line) { var cells = ParseTableRow(line); return cells.Count > 0 && cells.All(cell => { var trimmed = cell.Trim(); return trimmed.Contains('-', StringComparison.Ordinal) && trimmed.All(character => character is '-' or ':' or ' '); }); } private static IReadOnlyList ParseTableRow(string line) { var trimmed = line.Trim(); if (trimmed.StartsWith('|')) { trimmed = trimmed[1..]; } if (trimmed.EndsWith('|')) { trimmed = trimmed[..^1]; } return trimmed .Split('|') .Select(cell => cell.Trim()) .ToArray(); } private static void AddInline( List result, string text, WorkflowRulesEditorMarkdownInlineStyle style, string? linkTarget = null) { if (text.Length == 0) { return; } if (result.Count > 0 && result[^1].Style == style && result[^1].LinkTarget == linkTarget) { result[^1] = result[^1] with { Text = result[^1].Text + text }; return; } result.Add(new WorkflowRulesEditorMarkdownInline(text, style, linkTarget)); } private static int FindNextMarker(string text, int start) { var code = text.IndexOf('`', start); var image = text.IndexOf("![", start, StringComparison.Ordinal); var link = text.IndexOf('[', start); var autolink = text.IndexOf('<', start); var boldItalic = text.IndexOf("***", start, StringComparison.Ordinal); var strikethrough = text.IndexOf("~~", start, StringComparison.Ordinal); var bold = text.IndexOf("**", start, StringComparison.Ordinal); var italic = text.IndexOf('*', start); while (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*') { italic = text.IndexOf('*', italic + 2); } return new[] { code, image, link, autolink, boldItalic, strikethrough, bold, italic } .Where(index => index >= 0) .DefaultIfEmpty(-1) .Min(); } private static IEnumerable ReadLines(string text) { using var reader = new StringReader(text); while (reader.ReadLine() is { } line) { yield return line; } } }