< Summary

Information
Class: Chronicis.Api.Services.LinkParser
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/LinkParser.cs
Line coverage
100%
Covered lines: 39
Uncovered lines: 0
Coverable lines: 39
Total lines: 116
Line coverage: 100%
Branch coverage
95%
Covered branches: 21
Total branches: 22
Branch coverage: 95.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
ParseLinks(...)100%66100%
ParseHtmlLinks(...)87.5%88100%
ParseLegacyLinks(...)100%88100%

File(s)

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

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace Chronicis.Api.Services;
 4
 5/// <summary>
 6/// Parses wiki-style links from article content using regex pattern matching.
 7/// Supports both legacy markdown format and modern HTML span format.
 8/// </summary>
 9public class LinkParser : ILinkParser
 10{
 11    // Legacy regex pattern to match [[guid]] or [[guid|display text]]
 12    // Guid format: 8-4-4-4-12 hex characters with dashes
 113    private static readonly Regex LegacyLinkPattern = new(
 114        @"\[\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\|([^\]]+))?\]\]",
 115        RegexOptions.Compiled | RegexOptions.IgnoreCase
 116    );
 17
 18    // HTML span pattern for TipTap wiki-link nodes
 19    // Matches: <span ... data-target-id="guid" ...>Display Text</span>
 20    // Uses single quotes in the pattern to avoid C# escaping issues
 121    private static readonly Regex HtmlLinkPattern = new(
 122        @"<span[^>]+data-target-id=""([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})""[^>]
 123        RegexOptions.Compiled | RegexOptions.IgnoreCase
 124    );
 25
 26    /// <summary>
 27    /// Extracts all wiki links from the given article body.
 28    /// Supports both legacy [[guid|text]] format and HTML span format.
 29    /// </summary>
 30    /// <param name="body">The article body to parse.</param>
 31    /// <returns>Collection of parsed links with target ID, display text, and position.</returns>
 32    public IEnumerable<ParsedLink> ParseLinks(string? body)
 33    {
 34        // Return empty if body is null or empty
 3135        if (string.IsNullOrEmpty(body))
 36        {
 337            return Enumerable.Empty<ParsedLink>();
 38        }
 39
 2840        var links = new List<ParsedLink>();
 2841        var processedGuids = new HashSet<Guid>(); // Track unique links
 42
 43        // Parse HTML span format (TipTap output) - check for marker first
 2844        if (body.Contains("data-target-id="))
 45        {
 846            ParseHtmlLinks(body, links, processedGuids);
 47        }
 48
 49        // Parse legacy markdown format for backwards compatibility
 2850        if (body.Contains("[["))
 51        {
 1952            ParseLegacyLinks(body, links, processedGuids);
 53        }
 54
 2855        return links;
 56    }
 57
 58    private static void ParseHtmlLinks(string body, List<ParsedLink> links, HashSet<Guid> processedGuids)
 59    {
 860        var matches = HtmlLinkPattern.Matches(body);
 61
 3462        foreach (Match match in matches)
 63        {
 964            var guidString = match.Groups[1].Value;
 65
 966            if (!Guid.TryParse(guidString, out var targetArticleId))
 67            {
 68                continue;
 69            }
 70
 71            // Skip if we've already processed this target
 972            if (!processedGuids.Add(targetArticleId))
 73            {
 74                continue;
 75            }
 76
 77            // Get display text and trim whitespace
 978            var displayText = match.Groups[2].Success
 979                ? match.Groups[2].Value.Trim()
 980                : null;
 81
 982            var position = match.Index;
 83
 984            links.Add(new ParsedLink(targetArticleId, displayText, position));
 85        }
 886    }
 87
 88    private static void ParseLegacyLinks(string body, List<ParsedLink> links, HashSet<Guid> processedGuids)
 89    {
 1990        var matches = LegacyLinkPattern.Matches(body);
 91
 8292        foreach (Match match in matches)
 93        {
 2294            var guidString = match.Groups[1].Value;
 95
 2296            if (!Guid.TryParse(guidString, out var targetArticleId))
 97            {
 98                continue;
 99            }
 100
 101            // Skip if we've already processed this target (from HTML parsing)
 22102            if (!processedGuids.Add(targetArticleId))
 103            {
 104                continue;
 105            }
 106
 20107            var displayText = match.Groups[2].Success
 20108                ? match.Groups[2].Value.Trim()
 20109                : null;
 110
 20111            var position = match.Index;
 112
 20113            links.Add(new ParsedLink(targetArticleId, displayText, position));
 114        }
 19115    }
 116}