< Summary

Information
Class: Chronicis.Api.Services.HtmlToMarkdownConverter
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/HtmlToMarkdownConverter.cs
Line coverage
100%
Covered lines: 104
Uncovered lines: 0
Coverable lines: 104
Total lines: 327
Line coverage: 100%
Branch coverage
100%
Covered branches: 34
Total branches: 34
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Convert(...)100%22100%
ConvertWikiLinks(...)100%11100%
ConvertHeaders(...)100%22100%
ConvertInlineFormatting(...)100%11100%
ConvertLinks(...)100%11100%
ConvertCodeBlocks(...)100%11100%
ConvertBlockquotes(...)100%11100%
ConvertLists(...)100%22100%
ProcessList(...)100%88100%
ExtractListItems(...)100%1414100%
SplitNestedList(...)100%22100%
RenderNestedList(...)100%44100%
StripInlineTags(...)100%11100%
ConvertParagraphsAndBreaks(...)100%11100%
StripRemainingTags(...)100%11100%
NormalizeWhitespace(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/HtmlToMarkdownConverter.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.RegularExpressions;
 3
 4namespace Chronicis.Api.Services;
 5
 6/// <summary>
 7/// Converts TipTap HTML content to Markdown for export.
 8/// Pure text transformation with no external dependencies.
 9/// </summary>
 10public static partial class HtmlToMarkdownConverter
 11{
 12    // ── Compiled regex patterns ──────────────────────────────────────────────
 13
 14    [GeneratedRegex(@"<span[^>]*data-type=""wiki-link""[^>]*data-display=""([^""]+)""[^>]*>.*?</span>", RegexOptions.Ign
 15    private static partial Regex WikiLinkWithDisplay();
 16
 17    [GeneratedRegex(@"<span[^>]*data-type=""wiki-link""[^>]*>([^<]+)</span>", RegexOptions.IgnoreCase)]
 18    private static partial Regex WikiLinkPlain();
 19
 20    [GeneratedRegex(@"<strong[^>]*>(.*?)</strong>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 21    private static partial Regex StrongTag();
 22
 23    [GeneratedRegex(@"<b[^>]*>(.*?)</b>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 24    private static partial Regex BoldTag();
 25
 26    [GeneratedRegex(@"<em[^>]*>(.*?)</em>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 27    private static partial Regex EmTag();
 28
 29    [GeneratedRegex(@"<i[^>]*>(.*?)</i>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 30    private static partial Regex ItalicTag();
 31
 32    [GeneratedRegex(@"<a[^>]*href=""([^""]*)""[^>]*>(.*?)</a>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 33    private static partial Regex AnchorTag();
 34
 35    [GeneratedRegex(@"<pre[^>]*><code[^>]*>([\s\S]*?)</code></pre>", RegexOptions.IgnoreCase)]
 36    private static partial Regex PreCodeBlock();
 37
 38    [GeneratedRegex(@"<code[^>]*>(.*?)</code>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 39    private static partial Regex InlineCode();
 40
 41    [GeneratedRegex(@"<blockquote[^>]*>([\s\S]*?)</blockquote>", RegexOptions.IgnoreCase)]
 42    private static partial Regex BlockquoteTag();
 43
 44    [GeneratedRegex(@"<p[^>]*>(.*?)</p>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 45    private static partial Regex ParagraphTag();
 46
 47    [GeneratedRegex(@"<ul[^>]*>([\s\S]*)</ul>", RegexOptions.IgnoreCase)]
 48    private static partial Regex UnorderedList();
 49
 50    [GeneratedRegex(@"<ol[^>]*>([\s\S]*)</ol>", RegexOptions.IgnoreCase)]
 51    private static partial Regex OrderedList();
 52
 53    [GeneratedRegex(@"<li[^>]*>", RegexOptions.IgnoreCase)]
 54    private static partial Regex ListItemOpen();
 55
 56    [GeneratedRegex(@"</li>", RegexOptions.IgnoreCase)]
 57    private static partial Regex ListItemClose();
 58
 59    [GeneratedRegex(@"(<[uo]l[^>]*>[\s\S]*</[uo]l>)", RegexOptions.IgnoreCase)]
 60    private static partial Regex NestedList();
 61
 62    [GeneratedRegex(@"<[^>]+>")]
 63    private static partial Regex AnyTag();
 64
 65    [GeneratedRegex(@"<br\s*/?>", RegexOptions.IgnoreCase)]
 66    private static partial Regex BreakTag();
 67
 68    [GeneratedRegex(@"<hr[^>]*/?>", RegexOptions.IgnoreCase)]
 69    private static partial Regex HorizontalRule();
 70
 71    [GeneratedRegex(@"\n{3,}")]
 72    private static partial Regex ExcessiveNewlines();
 73
 74    /// <summary>
 75    /// Header patterns for h1–h6. Index 0 = h1, index 5 = h6.
 76    /// </summary>
 177    private static readonly Regex[] HeaderPatterns =
 178    [
 179        H1Regex(), H2Regex(), H3Regex(), H4Regex(), H5Regex(), H6Regex(),
 180    ];
 81
 82    [GeneratedRegex(@"<h1[^>]*>(.*?)</h1>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 83    private static partial Regex H1Regex();
 84
 85    [GeneratedRegex(@"<h2[^>]*>(.*?)</h2>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 86    private static partial Regex H2Regex();
 87
 88    [GeneratedRegex(@"<h3[^>]*>(.*?)</h3>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 89    private static partial Regex H3Regex();
 90
 91    [GeneratedRegex(@"<h4[^>]*>(.*?)</h4>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 92    private static partial Regex H4Regex();
 93
 94    [GeneratedRegex(@"<h5[^>]*>(.*?)</h5>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 95    private static partial Regex H5Regex();
 96
 97    [GeneratedRegex(@"<h6[^>]*>(.*?)</h6>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
 98    private static partial Regex H6Regex();
 99
 100    // ── Public API ───────────────────────────────────────────────────────────
 101
 102    /// <summary>
 103    /// Converts HTML content to Markdown.
 104    /// </summary>
 105    public static string Convert(string html)
 106    {
 33107        if (string.IsNullOrWhiteSpace(html))
 3108            return string.Empty;
 109
 30110        var markdown = html;
 111
 30112        markdown = ConvertWikiLinks(markdown);
 30113        markdown = ConvertHeaders(markdown);
 30114        markdown = ConvertInlineFormatting(markdown);
 30115        markdown = ConvertLinks(markdown);
 30116        markdown = ConvertCodeBlocks(markdown);
 30117        markdown = ConvertBlockquotes(markdown);
 30118        markdown = ConvertLists(markdown);
 30119        markdown = ConvertParagraphsAndBreaks(markdown);
 30120        markdown = StripRemainingTags(markdown);
 30121        markdown = System.Net.WebUtility.HtmlDecode(markdown);
 30122        markdown = NormalizeWhitespace(markdown);
 123
 30124        return markdown;
 125    }
 126
 127    // ── Wiki Links ──────────────────────────────────────────
 128
 129    internal static string ConvertWikiLinks(string html)
 130    {
 30131        var result = WikiLinkWithDisplay().Replace(html, "[[$1]]");
 30132        return WikiLinkPlain().Replace(result, "[[$1]]");
 133    }
 134
 135    // ── Headers ─────────────────────────────────────────────
 136
 137    internal static string ConvertHeaders(string html)
 138    {
 36139        var result = html;
 504140        for (int i = 0; i < 6; i++)
 141        {
 216142            var prefix = new string('#', i + 1);
 216143            result = HeaderPatterns[i].Replace(result, $"{prefix} $1\n\n");
 144        }
 36145        return result;
 146    }
 147
 148    // ── Inline Formatting ───────────────────────────────────
 149
 150    internal static string ConvertInlineFormatting(string html)
 151    {
 31152        var result = StrongTag().Replace(html, "**$1**");
 31153        result = BoldTag().Replace(result, "**$1**");
 31154        result = EmTag().Replace(result, "*$1*");
 31155        return ItalicTag().Replace(result, "*$1*");
 156    }
 157
 158    // ── Links ───────────────────────────────────────────────
 159
 160    internal static string ConvertLinks(string html)
 30161        => AnchorTag().Replace(html, "[$2]($1)");
 162
 163    // ── Code ────────────────────────────────────────────────
 164
 165    internal static string ConvertCodeBlocks(string html)
 166    {
 30167        var result = PreCodeBlock().Replace(html, "```\n$1\n```\n\n");
 30168        return InlineCode().Replace(result, "`$1`");
 169    }
 170
 171    // ── Blockquotes ─────────────────────────────────────────
 172
 173    internal static string ConvertBlockquotes(string html)
 174    {
 30175        return BlockquoteTag().Replace(html, m =>
 30176        {
 30177            var content = m.Groups[1].Value;
 30178            content = ParagraphTag().Replace(content, "$1");
 30179            var lines = content.Split('\n')
 30180                .Select(l => "> " + l.Trim())
 30181                .Where(l => l != "> ");
 30182            return string.Join("\n", lines) + "\n\n";
 30183        });
 184    }
 185
 186    // ── Lists ───────────────────────────────────────────────
 187
 188    internal static string ConvertLists(string html)
 189    {
 30190        var result = html;
 30191        var previous = "";
 192
 193        // Keep processing until no more changes (handles deep nesting)
 66194        while (result != previous)
 195        {
 36196            previous = result;
 197
 198            // Use greedy match so outermost list is captured first;
 199            // ProcessList handles nested <ul>/<ol> recursively within each <li>.
 36200            result = UnorderedList().Replace(result,
 36201                m => ProcessList(m.Groups[1].Value, ordered: false, indentLevel: 0));
 36202            result = OrderedList().Replace(result,
 36203                m => ProcessList(m.Groups[1].Value, ordered: true, indentLevel: 0));
 204        }
 205
 30206        return result;
 207    }
 208
 209    internal static string ProcessList(string listContent, bool ordered, int indentLevel)
 210    {
 14211        var sb = new StringBuilder();
 14212        var indent = new string(' ', indentLevel * 2);
 14213        var counter = 1;
 214
 215        // Extract list items accounting for nested lists.
 216        // We track <li> depth so we match the correct closing </li>.
 14217        var items = ExtractListItems(listContent);
 218
 70219        foreach (var itemContent in items)
 220        {
 21221            var (textContent, nestedListHtml) = SplitNestedList(itemContent);
 222
 21223            textContent = StripInlineTags(textContent);
 224
 21225            var prefix = ordered ? $"{counter}. " : "- ";
 21226            sb.AppendLine($"{indent}{prefix}{textContent}");
 21227            counter++;
 228
 21229            if (!string.IsNullOrEmpty(nestedListHtml))
 230            {
 4231                sb.Append(RenderNestedList(nestedListHtml, indentLevel + 1));
 232            }
 233        }
 234
 14235        if (indentLevel == 0)
 9236            sb.AppendLine();
 237
 14238        return sb.ToString();
 239    }
 240
 241    private static List<string> ExtractListItems(string listContent)
 242    {
 14243        var items = new List<string>();
 14244        var liOpens = ListItemOpen().Matches(listContent);
 245
 72246        foreach (Match openMatch in liOpens)
 247        {
 22248            var start = openMatch.Index + openMatch.Length;
 22249            var depth = 1;
 22250            var pos = start;
 251
 51252            while (pos < listContent.Length && depth > 0)
 253            {
 30254                var nextOpen = ListItemOpen().Match(listContent[pos..]);
 30255                var nextClose = ListItemClose().Match(listContent[pos..]);
 256
 30257                if (!nextClose.Success)
 258                    break;
 259
 29260                if (nextOpen.Success && nextOpen.Index < nextClose.Index)
 261                {
 4262                    depth++;
 4263                    pos += nextOpen.Index + nextOpen.Length;
 264                }
 265                else
 266                {
 25267                    depth--;
 25268                    if (depth == 0)
 269                    {
 21270                        items.Add(listContent[start..(pos + nextClose.Index)]);
 271                    }
 25272                    pos += nextClose.Index + nextClose.Length;
 273                }
 274            }
 275        }
 276
 14277        return items;
 278    }
 279
 280    private static (string text, string nestedHtml) SplitNestedList(string itemContent)
 281    {
 21282        var nestedMatch = NestedList().Match(itemContent);
 21283        if (!nestedMatch.Success)
 17284            return (itemContent, "");
 285
 4286        var text = itemContent[..nestedMatch.Index];
 4287        return (text, nestedMatch.Groups[1].Value);
 288    }
 289
 290    private static string RenderNestedList(string nestedHtml, int indentLevel)
 291    {
 4292        var sb = new StringBuilder();
 293
 4294        var ulMatch = UnorderedList().Match(nestedHtml);
 4295        if (ulMatch.Success)
 2296            sb.Append(ProcessList(ulMatch.Groups[1].Value, ordered: false, indentLevel));
 297
 4298        var olMatch = OrderedList().Match(nestedHtml);
 4299        if (olMatch.Success)
 2300            sb.Append(ProcessList(olMatch.Groups[1].Value, ordered: true, indentLevel));
 301
 4302        return sb.ToString();
 303    }
 304
 305    private static string StripInlineTags(string text)
 306    {
 21307        var result = ParagraphTag().Replace(text, "$1");
 21308        return AnyTag().Replace(result, "").Trim();
 309    }
 310
 311    // ── Paragraphs & Breaks ─────────────────────────────────
 312
 313    internal static string ConvertParagraphsAndBreaks(string html)
 314    {
 32315        var result = ParagraphTag().Replace(html, "$1\n\n");
 32316        result = BreakTag().Replace(result, "\n");
 32317        return HorizontalRule().Replace(result, "\n---\n\n");
 318    }
 319
 320    // ── Cleanup ─────────────────────────────────────────────
 321
 322    internal static string StripRemainingTags(string html)
 30323        => AnyTag().Replace(html, "");
 324
 325    internal static string NormalizeWhitespace(string text)
 31326        => ExcessiveNewlines().Replace(text, "\n\n").Trim();
 327}