File: Documentation\ApiDocs\ApiMemberMarkdownParser.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Hashing;
using System.Text;
using System.Text.RegularExpressions;
 
namespace Aspire.Cli.Documentation.ApiDocs;
 
/// <summary>
/// Parses member-level API entries from markdown pages that link to grouped member pages.
/// </summary>
internal static partial class ApiMemberMarkdownParser
{
    /// <summary>
    /// Parses member-level API items from a markdown page that contains grouped member links.
    /// </summary>
    /// <param name="containerItem">The parent page item.</param>
    /// <param name="markdown">The markdown content for the parent page.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <param name="memberGroupsByPageUrl">The known grouped member pages keyed by canonical page URL.</param>
    /// <returns>The parsed member items.</returns>
    public static IReadOnlyList<ApiReferenceItem> Parse(
        ApiReferenceItem containerItem,
        string markdown,
        string sitemapUrl,
        IReadOnlyDictionary<string, ApiReferenceItem> memberGroupsByPageUrl)
    {
        if (string.IsNullOrWhiteSpace(markdown) || memberGroupsByPageUrl.Count is 0)
        {
            return [];
        }
 
        var lines = markdown.Split('\n');
        var candidates = new List<ParsedMemberCandidate>();
 
        foreach (var line in lines)
        {
            if (!TryParseMemberOverviewLine(line, out var name, out var href, out var summary))
            {
                continue;
            }
 
            var pageUrl = ApiDocsSourceConfiguration.ResolveLinkedPageUrl(href, sitemapUrl);
            if (pageUrl is null ||
                !memberGroupsByPageUrl.TryGetValue(pageUrl, out var memberGroupItem) ||
                !string.Equals(memberGroupItem.ParentId, containerItem.Id, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            var anchor = ExtractFragment(href);
            if (string.IsNullOrWhiteSpace(anchor))
            {
                continue;
            }
 
            candidates.Add(new ParsedMemberCandidate(name, anchor, summary, memberGroupItem));
        }
 
        if (candidates.Count is 0)
        {
            return [];
        }
 
        var slugCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        foreach (var candidate in candidates)
        {
            var key = BuildSlugKey(candidate.MemberGroupItem.Id, candidate.Name);
            slugCounts[key] = slugCounts.TryGetValue(key, out var count) ? count + 1 : 1;
        }
 
        var items = new List<ApiReferenceItem>(candidates.Count);
        foreach (var candidate in candidates)
        {
            var memberId = BuildMemberId(candidate, slugCounts);
            items.Add(new ApiReferenceItem
            {
                Id = memberId,
                Name = candidate.Name,
                Language = candidate.MemberGroupItem.Language,
                Kind = ApiReferenceKinds.Member,
                PageUrl = $"{candidate.MemberGroupItem.PageUrl}#{candidate.Anchor}",
                ParentId = candidate.MemberGroupItem.Id,
                MemberGroup = candidate.MemberGroupItem.MemberGroup,
                Summary = candidate.Summary
            });
        }
 
        return items;
    }
 
    private static string? ExtractFragment(string href)
    {
        var fragmentSeparatorIndex = href.IndexOf('#');
        return fragmentSeparatorIndex >= 0 && fragmentSeparatorIndex < href.Length - 1
            ? href[(fragmentSeparatorIndex + 1)..].Trim()
            : null;
    }
 
    private static bool TryParseMemberOverviewLine(string line, out string name, out string href, out string? summary)
    {
        name = string.Empty;
        href = string.Empty;
        summary = null;
 
        var match = MemberOverviewRegex().Match(line.Trim());
        if (!match.Success)
        {
            return false;
        }
 
        href = match.Groups["href"].Value.Trim();
        if (href.Length is 0)
        {
            return false;
        }
 
        name = match.Groups["name"].Value.Trim();
        if (name.Length is 0)
        {
            return false;
        }
 
        var tail = match.Groups["tail"].Value.Trim();
        summary = ExtractSummaryFromTail(tail);
        return true;
    }
 
    private static string? ExtractSummaryFromTail(string tail)
    {
        if (string.IsNullOrWhiteSpace(tail))
        {
            return null;
        }
 
        var separatorIndex = tail.IndexOf(" -- ", StringComparison.Ordinal);
        if (separatorIndex < 0)
        {
            separatorIndex = tail.IndexOf(" — ", StringComparison.Ordinal);
        }
 
        return separatorIndex >= 0 && separatorIndex < tail.Length - 4
            ? tail[(separatorIndex + 4)..].Trim()
            : null;
    }
 
    [GeneratedRegex(@"^-\s+\[(?<name>[^\]]+)\]\((?<href>[^)]+)\)(?<tail>.*)$")]
    private static partial Regex MemberOverviewRegex();
 
    private static string BuildMemberId(ParsedMemberCandidate candidate, IReadOnlyDictionary<string, int> slugCounts)
    {
        var baseSlug = CreateMemberSlug(candidate.Name);
        var key = BuildSlugKey(candidate.MemberGroupItem.Id, candidate.Name);
        if (!slugCounts.TryGetValue(key, out var count) || count <= 1)
        {
            return $"{candidate.MemberGroupItem.Id}#{baseSlug}";
        }
 
        return $"{candidate.MemberGroupItem.Id}#{baseSlug}-{CreateShortSuffix(candidate.Anchor)}";
    }
 
    private static string BuildSlugKey(string memberGroupId, string memberName)
        => $"{memberGroupId}#{CreateMemberSlug(memberName)}";
 
    private static string CreateMemberSlug(string memberName)
    {
        var signatureStart = memberName.IndexOf('(');
        var baseName = signatureStart >= 0
            ? memberName[..signatureStart]
            : memberName;
 
        var slug = Slugify(baseName);
        return slug.Length is 0 ? "member" : slug;
    }
 
    private static string Slugify(string value)
    {
        var builder = new StringBuilder(value.Length);
        var pendingSeparator = false;
 
        foreach (var character in value)
        {
            if (char.IsLetterOrDigit(character))
            {
                if (pendingSeparator && builder.Length > 0)
                {
                    builder.Append('-');
                }
 
                builder.Append(char.ToLowerInvariant(character));
                pendingSeparator = false;
            }
            else
            {
                pendingSeparator = builder.Length > 0;
            }
        }
 
        return builder.ToString();
    }
 
    private static string CreateShortSuffix(string anchor)
    {
        var xxHash = new XxHash3();
        xxHash.Append(Encoding.UTF8.GetBytes(anchor));
        return Convert.ToHexString(xxHash.GetCurrentHash()).ToLowerInvariant()[..8];
    }
 
    private sealed record ParsedMemberCandidate(string Name, string Anchor, string? Summary, ApiReferenceItem MemberGroupItem);
}