Skip to content

Commit 407a4e6

Browse files
committed
chore: modify xml comment to markdown behaviors
1 parent 053e041 commit 407a4e6

17 files changed

+1629
-125
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Xml;
6+
using System.Xml.Linq;
7+
using System.Xml.XPath;
8+
9+
namespace Docfx.Dotnet;
10+
11+
internal partial class XmlComment
12+
{
13+
// List of block tags that are defined by CommonMark
14+
// https://spec.commonmark.org/0.31.2/#html-blocks
15+
private static readonly string[] BlockTags =
16+
{
17+
"ol",
18+
"p",
19+
"table",
20+
"ul",
21+
22+
// Recommended XML tags for C# documentation comments
23+
"example",
24+
25+
// Other tags
26+
"pre",
27+
};
28+
29+
private static readonly Lazy<string> BlockTagsXPath = new(string.Join(" | ", BlockTags.Select(tagName => $".//{tagName}")));
30+
31+
/// <summary>
32+
/// Gets markdown text from XElement.
33+
/// </summary>
34+
private static string GetMarkdownText(XElement elem)
35+
{
36+
// Gets HTML block tags by XPath.
37+
var nodes = elem.XPathSelectElements(BlockTagsXPath.Value).ToArray();
38+
39+
// Insert HTML/Markdown separator lines.
40+
foreach (var node in nodes)
41+
{
42+
if (node.NeedEmptyLineBefore())
43+
node.InsertEmptyLineBefore();
44+
45+
if (node.NeedEmptyLineAfter())
46+
node.AddAfterSelf(new XText("\n"));
47+
}
48+
49+
return elem.GetInnerXml();
50+
}
51+
52+
private static string GetInnerXml(XElement elem)
53+
=> elem.GetInnerXml();
54+
}
55+
56+
// Define file scoped extension methods.
57+
static file class XElementExtensions
58+
{
59+
/// <summary>
60+
/// Gets inner XML text of XElement.
61+
/// </summary>
62+
public static string GetInnerXml(this XElement elem)
63+
{
64+
using var sw = new StringWriter();
65+
using var writer = XmlWriter.Create(sw, new XmlWriterSettings
66+
{
67+
OmitXmlDeclaration = true,
68+
ConformanceLevel = ConformanceLevel.Fragment, // Required to write XML partial fragment
69+
Indent = false, // Preserve original indents
70+
NewLineChars = "\n", // Use LF
71+
});
72+
73+
var nodes = elem.Nodes().ToArray();
74+
foreach (var node in nodes)
75+
{
76+
node.WriteTo(writer);
77+
}
78+
writer.Flush();
79+
80+
var xml = sw.ToString();
81+
82+
// Remove shared indents.
83+
xml = RemoveCommonIndent(xml);
84+
85+
// Trim beginning spaces/lines if text starts with HTML tag.
86+
var firstNode = nodes.FirstOrDefault(x => !x.IsWhitespaceNode());
87+
if (firstNode != null && firstNode.NodeType == XmlNodeType.Element)
88+
xml = xml.TrimStart();
89+
90+
// Trim ending spaces/lines if text ends with HTML tag.
91+
var lastNode = nodes.LastOrDefault(x => !x.IsWhitespaceNode());
92+
if (lastNode != null && lastNode.NodeType == XmlNodeType.Element)
93+
xml = xml.TrimEnd();
94+
95+
return xml;
96+
}
97+
98+
public static bool NeedEmptyLineBefore(this XElement node)
99+
{
100+
if (!node.TryGetNonWhitespacePrevNode(out var prevNode))
101+
return false;
102+
103+
switch (prevNode.NodeType)
104+
{
105+
// If prev node is HTML element. No need to insert empty line.
106+
case XmlNodeType.Element:
107+
return false;
108+
109+
// Ensure empty lines exists before text node.
110+
case XmlNodeType.Text:
111+
var prevTextNode = (XText)prevNode;
112+
113+
// No need to insert line if prev node ends with empty line.
114+
if (prevTextNode.Value.EndsWithEmptyLine())
115+
return false;
116+
117+
return true;
118+
119+
default:
120+
return false;
121+
}
122+
}
123+
124+
public static void InsertEmptyLineBefore(this XElement elem)
125+
{
126+
if (!elem.TryGetNonWhitespacePrevNode(out var prevNode))
127+
return;
128+
129+
Debug.Assert(prevNode.NodeType == XmlNodeType.Text);
130+
131+
var prevTextNode = (XText)prevNode;
132+
var span = prevTextNode.Value.AsSpan();
133+
int index = span.LastIndexOf('\n');
134+
135+
ReadOnlySpan<char> lastLine = index == -1
136+
? span
137+
: span.Slice(index + 1);
138+
139+
if (lastLine.Length > 0 && lastLine.IsWhiteSpace())
140+
{
141+
// Insert new line before indent of last line.
142+
prevTextNode.Value = prevTextNode.Value.Insert(index, "\n");
143+
}
144+
else
145+
{
146+
elem.AddBeforeSelf(new XText("\n"));
147+
}
148+
}
149+
150+
private static bool EndsWithEmptyLine(this ReadOnlySpan<char> span)
151+
{
152+
var index = span.LastIndexOfAnyExcept([' ', '\t']);
153+
if (index >= 0 && span[index] == '\n')
154+
{
155+
span = span.Slice(0, index);
156+
index = span.LastIndexOfAnyExcept([' ', '\t']);
157+
if (index >= 0 && span[index] == '\n')
158+
return true;
159+
}
160+
161+
return false;
162+
}
163+
164+
private static bool TryGetNonWhitespacePrevNode(this XElement elem, out XNode result)
165+
{
166+
var prev = elem.PreviousNode;
167+
while (prev != null && prev.IsWhitespaceNode())
168+
prev = prev.PreviousNode;
169+
170+
if (prev == null)
171+
{
172+
result = null;
173+
return false;
174+
}
175+
176+
result = prev;
177+
return true;
178+
}
179+
180+
public static bool NeedEmptyLineAfter(this XElement node)
181+
{
182+
if (!node.TryGetNonWhitespaceNextNode(out var nextNode))
183+
return false;
184+
185+
switch (nextNode.NodeType)
186+
{
187+
// If next node is HTML element. No need to insert new line.
188+
case XmlNodeType.Element:
189+
return false;
190+
191+
// Ensure empty lines exists after node.
192+
case XmlNodeType.Text:
193+
var nextTextNode = (XText)nextNode;
194+
var textSpan = nextTextNode.Value.AsSpan();
195+
196+
// No need to insert line if prev node ends with empty line.
197+
if (textSpan.StartsWithEmptyLine())
198+
return false;
199+
200+
return true;
201+
202+
default:
203+
return false;
204+
}
205+
}
206+
private static bool StartsWithEmptyLine(this ReadOnlySpan<char> span)
207+
{
208+
var index = span.IndexOfAnyExcept([' ', '\t']);
209+
if (index >= 0 && span[index] == '\n')
210+
{
211+
++index;
212+
if (index > span.Length)
213+
return false;
214+
215+
span = span.Slice(index);
216+
index = span.IndexOfAnyExcept([' ', '\t']);
217+
218+
if (index < 0 || span[index] == '\n')
219+
return true; // There is no content or empty line is already exists.
220+
}
221+
return false;
222+
}
223+
224+
private static bool TryGetNonWhitespaceNextNode(this XElement elem, out XNode result)
225+
{
226+
var next = elem.NextNode;
227+
while (next != null && next.IsWhitespaceNode())
228+
next = next.NextNode;
229+
230+
if (next == null)
231+
{
232+
result = null;
233+
return false;
234+
}
235+
236+
result = next;
237+
return true;
238+
}
239+
240+
private static string RemoveCommonIndent(string text)
241+
{
242+
var lines = text.Split('\n').ToArray();
243+
244+
var inPre = false;
245+
var indentCounts = new List<int>();
246+
247+
// Caluculate line's indent chars (<pre></pre> tag region is excluded)
248+
foreach (var line in lines)
249+
{
250+
if (!inPre && !string.IsNullOrWhiteSpace(line))
251+
{
252+
int indent = line.TakeWhile(c => c == ' ' || c == '\t').Count();
253+
indentCounts.Add(indent);
254+
}
255+
256+
var trimmed = line.Trim();
257+
if (trimmed.StartsWith("<pre", StringComparison.OrdinalIgnoreCase))
258+
inPre = true;
259+
260+
if (trimmed.EndsWith("</pre>", StringComparison.OrdinalIgnoreCase))
261+
inPre = false;
262+
}
263+
264+
int minIndent = indentCounts.DefaultIfEmpty(0).Min();
265+
266+
inPre = false;
267+
var resultLines = new List<string>();
268+
foreach (var line in lines)
269+
{
270+
if (!inPre && line.Length >= minIndent)
271+
resultLines.Add(line.Substring(minIndent));
272+
else
273+
resultLines.Add(line);
274+
275+
// Update inPre flag.
276+
var trimmed = line.Trim();
277+
if (trimmed.StartsWith("<pre>", StringComparison.OrdinalIgnoreCase))
278+
inPre = true;
279+
if (trimmed.EndsWith("</pre>", StringComparison.OrdinalIgnoreCase))
280+
inPre = false;
281+
}
282+
283+
// Insert empty line to append `\n`.
284+
resultLines.Add("");
285+
286+
return string.Join("\n", resultLines);
287+
}
288+
289+
private static bool IsWhitespaceNode(this XNode node)
290+
{
291+
if (node is not XText textNode)
292+
return false;
293+
294+
return textNode.Value.All(char.IsWhiteSpace);
295+
}
296+
}

0 commit comments

Comments
 (0)