Skip to content

Commit f6b5434

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

17 files changed

+1617
-125
lines changed
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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+
// Check prev node ends with empty lines.
114+
if (prevTextNode.Value.EndsWithEmptyLines())
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+
public static bool NeedEmptyLineAfter(this XElement node)
151+
{
152+
if (!node.TryGetNonWhitespaceNextNode(out var nextNode))
153+
return false;
154+
155+
switch (nextNode.NodeType)
156+
{
157+
// If next node is HTML element. No need to insert new line.
158+
case XmlNodeType.Element:
159+
return false;
160+
161+
// Ensure empty lines exists after node.
162+
case XmlNodeType.Text:
163+
var nextTextNode = (XText)nextNode;
164+
var textSpan = nextTextNode.Value.AsSpan();
165+
166+
// Return `false` if next node start with empty line.
167+
var index = textSpan.IndexOfAnyExcept([' ', '\t']);
168+
if (index >= 0 && textSpan[index] == '\n')
169+
return false;
170+
171+
return true;
172+
173+
default:
174+
return false;
175+
}
176+
}
177+
178+
private static bool IsWhitespaceNode(this XNode node)
179+
{
180+
if (node is not XText textNode)
181+
return false;
182+
183+
return textNode.Value.All(char.IsWhiteSpace);
184+
}
185+
186+
private static string RemoveCommonIndent(string text)
187+
{
188+
var lines = text.Split('\n').ToArray();
189+
190+
var inPre = false;
191+
var indentCounts = new List<int>();
192+
193+
// Caluculate line's indent chars (<pre></pre> tag region is excluded)
194+
foreach (var line in lines)
195+
{
196+
if (!inPre && !string.IsNullOrWhiteSpace(line))
197+
{
198+
int indent = line.TakeWhile(c => c == ' ' || c == '\t').Count();
199+
indentCounts.Add(indent);
200+
}
201+
202+
var trimmed = line.Trim();
203+
if (trimmed.StartsWith("<pre", StringComparison.OrdinalIgnoreCase))
204+
inPre = true;
205+
206+
if (trimmed.EndsWith("</pre>", StringComparison.OrdinalIgnoreCase))
207+
inPre = false;
208+
}
209+
210+
int minIndent = indentCounts.DefaultIfEmpty(0).Min();
211+
212+
inPre = false;
213+
var resultLines = new List<string>();
214+
foreach (var line in lines)
215+
{
216+
if (!inPre && line.Length >= minIndent)
217+
resultLines.Add(line.Substring(minIndent));
218+
else
219+
resultLines.Add(line);
220+
221+
// Update inPre flag.
222+
var trimmed = line.Trim();
223+
if (trimmed.StartsWith("<pre>", StringComparison.OrdinalIgnoreCase))
224+
inPre = true;
225+
if (trimmed.EndsWith("</pre>", StringComparison.OrdinalIgnoreCase))
226+
inPre = false;
227+
}
228+
229+
// Insert empty line to append `\n`.
230+
resultLines.Add("");
231+
232+
return string.Join("\n", resultLines);
233+
}
234+
235+
private static bool TryGetNonWhitespacePrevNode(this XElement elem, out XNode result)
236+
{
237+
var prev = elem.PreviousNode;
238+
while (prev != null && prev.IsWhitespaceNode())
239+
prev = prev.PreviousNode;
240+
241+
if (prev == null)
242+
{
243+
result = null;
244+
return false;
245+
}
246+
247+
result = prev;
248+
return true;
249+
}
250+
251+
private static bool TryGetNonWhitespaceNextNode(this XElement elem, out XNode result)
252+
{
253+
var next = elem.NextNode;
254+
while (next != null && next.IsWhitespaceNode())
255+
next = next.NextNode;
256+
257+
if (next == null)
258+
{
259+
result = null;
260+
return false;
261+
}
262+
263+
result = next;
264+
return true;
265+
}
266+
267+
/// <summary>
268+
/// Helper method to check if text ends with empty lines.
269+
/// </summary>
270+
private static bool EndsWithEmptyLines(this ReadOnlySpan<char> text)
271+
{
272+
var index = text.LastIndexOfAnyExcept([' ', '\t']);
273+
if (index >= 0 && text[index] == '\n')
274+
{
275+
var span = text.Slice(0, index);
276+
index = span.LastIndexOfAnyExcept([' ', '\t']);
277+
if (index >= 0 && text[index] == '\n')
278+
return true;
279+
}
280+
281+
return false;
282+
}
283+
}

0 commit comments

Comments
 (0)