File: Documentation\ApiDocs\ApiDocsSourceConfiguration.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.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Cli.Documentation.ApiDocs;
 
/// <summary>
/// Resolves configuration for Aspire API docs sources.
/// </summary>
internal static partial class ApiDocsSourceConfiguration
{
    private const string IndexCacheKeyPrefix = "index:";
    private const string MemberIndexCacheKeyPrefix = "member-index:";
 
    /// <summary>
    /// Configuration path for overriding the API sitemap URL.
    /// </summary>
    public const string SitemapUrlConfigPath = "docs:api:sitemapUrl";
 
    /// <summary>
    /// Default sitemap URL for Aspire API reference pages.
    /// </summary>
    public const string DefaultSitemapUrl = "https://aspire.dev/sitemap-0.xml";
 
    /// <summary>
    /// Gets the sitemap URL used to build the API index.
    /// </summary>
    /// <param name="configuration">The configuration to read from.</param>
    /// <returns>The resolved sitemap URL.</returns>
    public static string GetSitemapUrl(IConfiguration configuration)
        => configuration[SitemapUrlConfigPath] ?? DefaultSitemapUrl;
 
    /// <summary>
    /// Gets a source-specific cache key for the parsed API index.
    /// </summary>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The cache key used for the parsed API index.</returns>
    public static string GetIndexCacheKey(string sitemapUrl)
        => $"{IndexCacheKeyPrefix}{GetSitemapContentCacheKey(sitemapUrl)}";
 
    /// <summary>
    /// Gets a source-specific cache key for the parsed member index.
    /// </summary>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The cache key used for the parsed member index.</returns>
    public static string GetMemberIndexCacheKey(string sitemapUrl)
        => $"{MemberIndexCacheKeyPrefix}{GetSitemapContentCacheKey(sitemapUrl)}";
 
    /// <summary>
    /// Gets the cache key used for fetched sitemap content.
    /// </summary>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The cache key used for sitemap content and ETag persistence.</returns>
    public static string GetSitemapContentCacheKey(string sitemapUrl)
        => DocumentationCacheKey.FromUrl(sitemapUrl, "sitemap");
 
    /// <summary>
    /// Replaces the scheme, host, and port of a canonical API page URL with the configured sitemap source.
    /// </summary>
    /// <param name="pageUrl">The canonical API page URL from the sitemap body.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The page URL rewritten to the configured host when both URLs are absolute; otherwise, the original page URL.</returns>
    public static string RebasePageUrl(string pageUrl, string sitemapUrl)
    {
        if (!Uri.TryCreate(pageUrl, UriKind.Absolute, out var pageUri) ||
            !Uri.TryCreate(sitemapUrl, UriKind.Absolute, out var sitemapUri))
        {
            return pageUrl;
        }
 
        if (Uri.Compare(pageUri, sitemapUri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) is 0)
        {
            return pageUrl;
        }
 
        var rebasedUri = new UriBuilder(pageUri)
        {
            Scheme = sitemapUri.Scheme,
            Host = sitemapUri.Host,
            Port = sitemapUri.IsDefaultPort ? -1 : sitemapUri.Port
        };
 
        return rebasedUri.Uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
    }
 
    /// <summary>
    /// Resolves a page URL to the markdown URL that should be fetched.
    /// </summary>
    /// <param name="pageUrl">The canonical page URL.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The markdown URL for the page.</returns>
    public static string BuildMarkdownUrl(string pageUrl, string sitemapUrl)
    {
        pageUrl = StripFragment(pageUrl);
        pageUrl = RebasePageUrl(pageUrl, sitemapUrl);
 
        if (pageUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
        {
            return pageUrl;
        }
 
        return $"{pageUrl.TrimEnd('/')}.md";
    }
 
    /// <summary>
    /// Gets the cache key used for a fetched API markdown page.
    /// </summary>
    /// <param name="pageUrl">The canonical API page URL.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The cache key used for page content and ETag persistence.</returns>
    public static string GetPageContentCacheKey(string pageUrl, string sitemapUrl)
        => DocumentationCacheKey.FromUrl(BuildMarkdownUrl(pageUrl, sitemapUrl), "page");
 
    /// <summary>
    /// Rewrites site-relative markdown links to absolute URLs on the configured host so returned content is clickable.
    /// </summary>
    /// <param name="markdown">The markdown content to normalize.</param>
    /// <param name="pageUrl">The canonical page URL that produced the markdown.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The markdown with rewritten link targets.</returns>
    public static string RewriteMarkdownLinks(string markdown, string pageUrl, string sitemapUrl)
    {
        if (string.IsNullOrWhiteSpace(markdown) ||
            (!markdown.Contains("](/", StringComparison.Ordinal) && !markdown.Contains("](#", StringComparison.Ordinal)) ||
            !Uri.TryCreate(sitemapUrl, UriKind.Absolute, out var sitemapUri))
        {
            return markdown;
        }
 
        var siteRoot = sitemapUri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
        var currentPageUrl = RebasePageUrl(StripFragment(pageUrl), sitemapUrl);
 
        return LocalMarkdownLinkRegex().Replace(markdown, match =>
        {
            var href = NormalizeMarkdownHref(match.Groups["href"].Value);
            var rewrittenHref = href[0] switch
            {
                '/' => $"{siteRoot}{href}",
                '#' => $"{currentPageUrl}{href}",
                _ => null
            };
 
            return rewrittenHref is null
                ? match.Value
                : $"[{match.Groups["text"].Value}]({rewrittenHref})";
        });
    }
 
    /// <summary>
    /// Resolves a markdown link from an API markdown page to its canonical non-markdown page URL.
    /// </summary>
    /// <param name="href">The markdown link target.</param>
    /// <param name="sitemapUrl">The configured sitemap URL.</param>
    /// <returns>The resolved canonical page URL, or <c>null</c> if the link cannot be resolved.</returns>
    public static string? ResolveLinkedPageUrl(string href, string sitemapUrl)
    {
        if (string.IsNullOrWhiteSpace(href) || !Uri.TryCreate(sitemapUrl, UriKind.Absolute, out var sitemapUri))
        {
            return null;
        }
 
        href = NormalizeMarkdownHref(href);
 
        Uri? resolvedUri;
        if (TryCreateHttpUri(href, out var httpUri))
        {
            resolvedUri = httpUri;
        }
        else
        {
            var siteRootUri = new Uri(sitemapUri.GetLeftPart(UriPartial.Authority));
            if (!Uri.TryCreate(siteRootUri, href, out var relativeUri) || relativeUri is null)
            {
                return null;
            }
 
            resolvedUri = relativeUri;
        }
 
        if (resolvedUri is null)
        {
            return null;
        }
 
        var pageUrl = StripFragment(resolvedUri.GetLeftPart(UriPartial.Path));
        if (pageUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
        {
            pageUrl = pageUrl[..^3];
        }
 
        return RebasePageUrl(pageUrl, sitemapUrl);
    }
 
    private static bool TryCreateHttpUri(string href, out Uri? resolvedUri)
    {
        if (Uri.TryCreate(href, UriKind.Absolute, out resolvedUri) &&
            (resolvedUri.Scheme == Uri.UriSchemeHttp || resolvedUri.Scheme == Uri.UriSchemeHttps))
        {
            return true;
        }
 
        resolvedUri = null;
        return false;
    }
 
    private static string NormalizeMarkdownHref(string href)
    {
        href = href.Trim();
        if (href.Length > 1 && href[0] is '<' && href[^1] is '>')
        {
            href = href[1..^1];
        }
 
        var titleSeparatorIndex = href.IndexOf(' ');
        return titleSeparatorIndex > 0
            ? href[..titleSeparatorIndex]
            : href;
    }
 
    private static string StripFragment(string pageUrl)
    {
        var fragmentSeparatorIndex = pageUrl.IndexOf('#');
        return fragmentSeparatorIndex >= 0
            ? pageUrl[..fragmentSeparatorIndex]
            : pageUrl;
    }
 
    [GeneratedRegex(@"\[(?<text>(?:[^\[\]]|\[[^\[\]]*\])+)\]\((?<href>(?:/|#)[^)]*)\)")]
    private static partial Regex LocalMarkdownLinkRegex();
}