namespace Dpz.Core.Web.Models;

/// <summary>
/// Markdown 链接解析结果分类
/// </summary>
public enum CodeReadmeLinkKind
{
    /// <summary>
    /// 无需处理(空链接 / 同页锚点 / 查询串)
    /// </summary>
    Keep = 0,

    /// <summary>
    /// 外部链接(浏览器新窗口打开)
    /// </summary>
    External = 1,

    /// <summary>
    /// 仓库内路径(重写为代码浏览页地址)
    /// </summary>
    RepoPath = 2,
}

/// <summary>
/// Markdown 链接解析结果
/// </summary>
public sealed record CodeReadmeLinkResolution(
    CodeReadmeLinkKind Kind,
    string? RepoPath,
    string? Suffix
)
{
    public static CodeReadmeLinkResolution Keep { get; } = new(CodeReadmeLinkKind.Keep, null, null);

    public static CodeReadmeLinkResolution External { get; } =
        new(CodeReadmeLinkKind.External, null, null);

    public static CodeReadmeLinkResolution Repo(string repoPath, string? suffix) =>
        new(CodeReadmeLinkKind.RepoPath, repoPath, suffix);
}

/// <summary>
/// 代码浏览页 Markdown 链接解析:以文档所在目录为基准,
/// 将仓库相对路径折叠为库内绝对路径(渲染期零查库)
/// </summary>
public static class CodeReadmeLinkResolver
{
    private static readonly HashSet<string> ExternalSchemes = new(StringComparer.OrdinalIgnoreCase)
    {
        "http",
        "https",
        "ftp",
        "mailto",
        "tel",
        "data",
    };

    /// <summary>
    /// 解析一个链接 URL
    /// </summary>
    /// <param name="url">Markdown 链接原文(可为空)</param>
    /// <param name="basePaths">文档所在目录的路径分段(根目录为空集合)</param>
    /// <returns>链接分类与仓库内路径。</returns>
    public static CodeReadmeLinkResolution Resolve(
        string? url,
        IReadOnlyCollection<string> basePaths
    )
    {
        url = url?.Trim();
        if (string.IsNullOrEmpty(url))
        {
            return CodeReadmeLinkResolution.Keep;
        }

        var suffix = "";
        var suffixStart = url.IndexOfAny(['#', '?']);
        if (suffixStart >= 0)
        {
            suffix = url[suffixStart..];
            url = url[..suffixStart];
        }

        if (string.IsNullOrEmpty(url))
        {
            return CodeReadmeLinkResolution.Keep;
        }

        if (
            url.StartsWith("//", StringComparison.Ordinal)
            || (
                Uri.TryCreate(url, UriKind.Absolute, out var uri)
                && ExternalSchemes.Contains(uri.Scheme)
            )
        )
        {
            return CodeReadmeLinkResolution.External;
        }

        var segments = new List<string>(basePaths.Count);
        foreach (var item in basePaths)
        {
            if (!string.IsNullOrWhiteSpace(item))
            {
                segments.Add(item);
            }
        }

        url = url.Replace('\\', '/').TrimStart('/');
        foreach (var part in url.Split('/', StringSplitOptions.RemoveEmptyEntries))
        {
            if (part == ".")
            {
                continue;
            }

            if (part == "..")
            {
                if (segments.Count > 0)
                {
                    segments.RemoveAt(segments.Count - 1);
                }

                continue;
            }

            segments.Add(part);
        }

        var repoPath = string.Join('/', segments);
        return CodeReadmeLinkResolution.Repo(repoPath, suffix.Length == 0 ? null : suffix);
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

该文件位于代码仓库浏览功能的渲染层,职责非常单一清晰:在把仓库内 Markdown(README / 单文件 Markdown / AI 分析结果)渲染成 HTML 之前,对其中每个普通文本链接做"分类 + 路径归一化",为后续改写链接地址做准备。它本身不访问数据库、不做任何 IO,属于纯字符串级运算,正如其文档注释所说"渲染期零查库"。

文件内部结构

文件中实际声明了三个类型(同一命名空间 [Dpz.Core.Web.Models]):

  1. CodeReadmeLinkKind(枚举):链接解析后的三类结果

    • Keep:不需要处理——空链接、纯锚点(#xxx)、纯查询串(?x=y)等页内引用;
    • External:外部链接,渲染时浏览器新窗口打开;
    • RepoPath:仓库内路径,渲染时改写为代码浏览页地址。
  2. CodeReadmeLinkResolution(sealed record):解析结果载体,携带 Kind、折叠后的库内相对路径 RepoPath、保留下来的锚点/查询后缀 Suffix,并提供三个工厂方法 Keep / External / Repo。

  3. CodeReadmeLinkResolver(static class):核心解析逻辑,内含一个大小写不敏感的外部协议集合 ExternalSchemes(http/https/ftp/mailto/tel/data),并提供静态方法 Resolve(url, basePaths)。

Resolve 的执行流程

basePaths 表示"文档所在目录的路径分段"(根目录为空集合),README/Markdown 文档用文档所在目录,而 AI 分析结果中的链接以仓库根相对路径出现时传空集合,从而以仓库根为基准解析。

flowchart TD
    A[url Trim 后为空?] -->|是| K1[返回 Keep]
    A -->|否| B[截取 # 或 ? 起始的后缀 suffix<br/>并从前部 URL 中剥离]
    B --> C[剥离后前部 URL 为空?] -->|是| K2[返回 Keep<br/>纯锚点/查询串]
    C -->|否| D{协议相对 // 开头<br/>或 Uri 绝对且 scheme 命中外部集合?}
    D -->|是| E[返回 External]
    D -->|否| F[segments 初始化为 basePaths<br/>过滤空项]
    F --> G[把反斜杠转正斜杠、去前导 /、按 / 分段]
    G --> H{逐段处理}
    H -->|'.'| Skip[跳过]
    H -->|'..'| Pop[segments 非空则弹出末尾段]
    H -->|其它| Push[追加为一段]
    Skip --> I[用 / 连接 segments 得 repoPath]
    Pop --> I
    Push --> I
    I --> J[suffix 为空则传 null]
    J --> K3[返回 Repo repoPath + suffix]
    K1 --> END[结束]
    K2 --> END
    E --> END
    K3 --> END

几个值得注意的细节:

  • 后缀剥离:#(锚点)与 ?(查询串)都先于路径判断被截出,最终 RepoPath 改写时由调用方把后缀拼回,避免路径折叠被污染;纯 #/? 链接直接判 Keep,不参与改写。
  • 路径折叠是"手工模拟",不依赖 Uri 的解析:用 . 忽略、.. 弹出上一步骤,等价于把"以文档目录为基准的相对路径"折叠为"库内根绝对路径"。越界(.. 弹出时 segments 已空)则静默丢弃,不报错。
  • 反斜杠容错:\ 一律先替换为 /,兼容 Windows 风格相对路径写法。
  • 外部判定:// 开头的协议相对地址与常见协议集合命中的绝对地址都判外部;未知 scheme 的绝对 URI 不在此列,会落入仓库路径分支(按库内路径处理)。

与相关文件的协作

  • Views/Code/_ReadmePartial.cshtml 是唯一服务端调用方:用 Markdig 解析 Markdown 后遍历 LinkInline(跳过图片链接),调用 CodeReadmeLinkResolver.Resolve(link.Url, Model.BasePaths),随后按结果改写:
    • RepoPath → 用 Url.Action("Index", new { Path = resolution.RepoPath }) 生成代码浏览页地址,若 Suffix 非空则拼接回去;
    • External → 加 target="_blank";
    • Keep → 不处理。
  • Models/ReadmeContentModel.cs 定义渲染模型,BasePaths 的语义注释与解析器约定一致(README 用文档所在目录,AI 分析传空集合从根解析)。
  • Models/CodeContentView.cs 承载单文件预览时的 BasePaths,Views/Code/_CodeView.cshtml 在 Language 为 markdown 时把它包装为 ReadmeContentModel 交给 _ReadmePartial。
  • Views/Code/_CodeRowsPartial.cshtml 在目录树场景渲染 README 与 AI 分析结果,目录用文档所在目录、AI 结果传空 [],对应"AI 链接以仓库根路径出现"的约定。
  • 前端 wwwroot/scripts/modules/CodeAiLive.ts 中有一段注释说明它是镜像服务端 CodeReadmeLinkResolver 规则的客户端实现(针对 AI 实时分析结果中的链接,从仓库根解析),前后端保持同一套解析规则。

小结

该文件的价值在于把"README 里相对路径的链接"在渲染期统一折叠为库内绝对路径并分类(库内 / 外部 / 保留),把改写逻辑集中在一个可单测的纯静态方法里,避免把路径字符串操作散落在 Razor 视图中,同时与前端 TS 镜像保持规则一致。

补充说明:basePaths 在控制器侧如何由当前浏览路径切分而来(如去掉文件名、保留目录段)我未进一步核实,代码注释与视图模型足以说明其语义("文档所在目录的路径分段"),如需精确来源可继续跟踪 Code 控制器的构造逻辑。

评论加载中...