Forge
csharp3407750f
1using System.Text;
2using System.Text.RegularExpressions;
3using Markdig;
4
5namespace AgentForge.Plugin.View.Components.Docs;
6
7internal readonly record struct ForgeViewMarkdownContext(string RepoName, string DocsRelativePath);
8
9internal static partial class ForgeViewMarkdown
10{
11 private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
12 .UseAdvancedExtensions()
13 .Build();
14
15 internal static string Render(string markdown, ForgeViewMarkdownContext? context = null)
16 {
17 var prepared = PreprocessMkDocsAdmonitions(markdown);
18 var html = Markdown.ToHtml(prepared, Pipeline);
19 if (context is not null)
20 html = RewriteDocsLinks(html, context.Value);
21 return ForgeHtml.Raw(html);
22 }
23
24 private static string PreprocessMkDocsAdmonitions(string markdown)
25 {
26 var lines = markdown.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
27 var output = new StringBuilder();
28 for (var i = 0; i < lines.Length;)
29 {
30 var line = lines[i];
31 if (!line.StartsWith("!!! ", StringComparison.Ordinal)
32 && !line.StartsWith("??? ", StringComparison.Ordinal))
33 {
34 output.AppendLine(line);
35 i++;
36 continue;
37 }
38
39 var header = AdmonitionHeaderRegex().Match(line);
40 if (!header.Success)
41 {
42 output.AppendLine(line);
43 i++;
44 continue;
45 }
46
47 var type = header.Groups["type"].Value;
48 var title = header.Groups["title"].Success
49 ? header.Groups["title"].Value
50 : char.ToUpperInvariant(type[0]) + type[1..];
51 i++;
52
53 var bodyLines = new List<string>();
54 while (i < lines.Length && IsAdmonitionBodyLine(lines[i]))
55 {
56 bodyLines.Add(StripAdmonitionIndent(lines[i]));
57 i++;
58 }
59
60 var bodyHtml = bodyLines.Count == 0
61 ? ""
62 : Markdown.ToHtml(string.Join('\n', bodyLines), Pipeline);
63
64 output.Append("<div class=\"admonition ").Append(type).AppendLine("\">");
65 output.Append("<p class=\"admonition-title\">").Append(ForgeHtml.H(title)).AppendLine("</p>");
66 output.AppendLine(bodyHtml);
67 output.AppendLine("</div>");
68 }
69
70 return output.ToString();
71 }
72
73 private static bool IsAdmonitionBodyLine(string line) =>
74 line.StartsWith(" ", StringComparison.Ordinal) || line.StartsWith('\t');
75
76 private static string StripAdmonitionIndent(string line) =>
77 line.StartsWith(" ", StringComparison.Ordinal) ? line[4..] : line.TrimStart('\t');
78
79 private static string RewriteDocsLinks(string html, ForgeViewMarkdownContext context)
80 {
81 return LinkHrefRegex().Replace(html, match =>
82 {
83 var href = match.Groups["href"].Value;
84 if (!ShouldRewriteDocsLink(href))
85 return match.Value;
86
87 var hashIndex = href.IndexOf('#');
88 var fragment = hashIndex >= 0 ? href[hashIndex..] : "";
89 var pathPart = hashIndex >= 0 ? href[..hashIndex] : href;
90 var resolved = ResolveDocsRelativePath(context.DocsRelativePath, pathPart);
91 var newHref = ForgeViewLinks.DocsHref(context.RepoName, resolved) + fragment;
92 return match.Value.Replace(href, newHref, StringComparison.Ordinal);
93 });
94 }
95
96 private static bool ShouldRewriteDocsLink(string href) =>
97 href.Length > 0
98 && href[0] != '#'
99 && !href.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
100 && !href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
101 && !href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)
102 && !href.StartsWith('/');
103
104 internal static string ResolveDocsRelativePath(string? baseDocsRelativePath, string relativeHref)
105 {
106 var normalizedBase = (baseDocsRelativePath ?? "").Trim().Replace('\\', '/');
107 var baseDir = normalizedBase.Contains('/')
108 ? normalizedBase[..normalizedBase.LastIndexOf('/')]
109 : "";
110
111 string combined;
112 if (relativeHref.StartsWith('/'))
113 combined = relativeHref.TrimStart('/');
114 else if (baseDir.Length > 0)
115 combined = $"{baseDir}/{relativeHref}";
116 else
117 combined = relativeHref;
118
119 return NormalizeDocPath(combined);
120 }
121
122 private static string NormalizeDocPath(string path)
123 {
124 var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
125 var stack = new List<string>(segments.Length);
126 foreach (var segment in segments)
127 {
128 if (segment == ".")
129 continue;
130 if (segment == "..")
131 {
132 if (stack.Count > 0)
133 stack.RemoveAt(stack.Count - 1);
134 continue;
135 }
136
137 stack.Add(segment);
138 }
139
140 return string.Join('/', stack);
141 }
142
143 [GeneratedRegex(@"^[!?]{3}\s+(?<type>[a-zA-Z-]+)(?:\s+""(?<title>[^""]+)"")?\s*$")]
144 private static partial Regex AdmonitionHeaderRegex();
145
146 [GeneratedRegex("""<a\s+(?:[^>]*?\s+)?href="(?<href>[^"]*)"[^>]*>""", RegexOptions.IgnoreCase)]
147 private static partial Regex LinkHrefRegex();
148}
149
View only · write via MCP/CIDE