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);
}
}
该文件位于代码仓库浏览功能的渲染层,职责非常单一清晰:在把仓库内 Markdown(README / 单文件 Markdown / AI 分析结果)渲染成 HTML 之前,对其中每个普通文本链接做"分类 + 路径归一化",为后续改写链接地址做准备。它本身不访问数据库、不做任何 IO,属于纯字符串级运算,正如其文档注释所说"渲染期零查库"。
文件内部结构
文件中实际声明了三个类型(同一命名空间 [Dpz.Core.Web.Models]):
CodeReadmeLinkKind(枚举):链接解析后的三类结果Keep:不需要处理——空链接、纯锚点(#xxx)、纯查询串(?x=y)等页内引用;External:外部链接,渲染时浏览器新窗口打开;RepoPath:仓库内路径,渲染时改写为代码浏览页地址。
CodeReadmeLinkResolution(sealed record):解析结果载体,携带Kind、折叠后的库内相对路径RepoPath、保留下来的锚点/查询后缀Suffix,并提供三个工厂方法Keep/External/Repo。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 控制器的构造逻辑。