diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
index f09cd4a6f3..1febf730f7 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProvider.cs
@@ -16,6 +16,11 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep
{
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
+ // Matches , , ,
+ private static readonly Regex XmlRefTagRegex = new(
+ @"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?[^""]+)""\s*/?>",
+ RegexOptions.Compiled);
+
private readonly ConcurrentDictionary> _xmlDocCache = new();
public virtual async Task GetSummaryAsync(Type type)
@@ -117,13 +122,39 @@ public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDep
return null;
}
- var text = element.Value;
- if (string.IsNullOrWhiteSpace(text))
+ // Convert to string first so we can process inline XML tags like
+ var raw = element.ToString();
+
+ // Strip the outer element tags (e.g. ...)
+ var start = raw.IndexOf('>') + 1;
+ var end = raw.LastIndexOf('<');
+ if (start >= end)
+ {
+ return null;
+ }
+
+ var inner = raw[start..end];
+
+ // Replace with the short name "Bar"
+ // Replace with "null"
+ // Replace and with the name
+ inner = XmlRefTagRegex.Replace(inner, m =>
+ {
+ var display = m.Groups["display"].Value;
+ // For cref values like "T:Foo.Bar.Baz", return only "Baz"
+ var dot = display.LastIndexOf('.');
+ return dot >= 0 ? display[(dot + 1)..] : display;
+ });
+
+ // Strip any remaining XML tags (e.g. , , , , etc.)
+ inner = Regex.Replace(inner, @"<[^>]+>", string.Empty);
+
+ if (string.IsNullOrWhiteSpace(inner))
{
return null;
}
- return WhitespaceRegex.Replace(text.Trim(), " ");
+ return WhitespaceRegex.Replace(inner.Trim(), " ");
}
private static string GetMemberNameForType(Type type)
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs
new file mode 100644
index 0000000000..01b5f6b060
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/XmlDocumentationProviderTests.cs
@@ -0,0 +1,213 @@
+#nullable enable
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Shouldly;
+using Volo.Abp.DependencyInjection;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Mvc.ApiExploring;
+
+public class XmlDocumentationProviderTests
+{
+ // A stub type so we can construct member-name keys that the provider can look up.
+ private class StubType { }
+
+ private static XmlDocumentationProvider CreateProvider(string xmlDocBody)
+ {
+ var xml = $@"
+
+
+ {xmlDocBody}
+
+";
+ return new FakeXmlDocumentationProvider(xml);
+ }
+
+ private static string StubTypeMemberName(string elementName, string xmlContent)
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ return $@"
+ {xmlContent}
+ ";
+ }
+
+ // Tests for CleanXmlText via GetSummaryAsync(Type)
+
+ [Fact]
+ public async Task GetSummary_Returns_PlainText()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"A simple summary.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("A simple summary.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_SeeCref_To_ShortTypeName()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Returns a value.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Returns a String value.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_SeeCref_NestedType()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"See for details.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("See List`1 for details.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_SeeLangword()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Returns when not found.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Returns null when not found.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Strips_CodeTag()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Use DoSomething() to start.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Use DoSomething() to start.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Strips_ParaTag()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"First paragraph.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("First paragraph.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Collapses_Whitespace()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"
+ Multiple
+ spaces here.
+ ");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Multiple spaces here.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Returns_Null_When_Member_Not_Found()
+ {
+ var provider = CreateProvider(string.Empty);
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetSummary_Returns_Null_When_Summary_Is_Empty()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@" ");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_Paramref()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Use the parameter.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Use the input parameter.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_Typeparamref()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Returns instance.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Returns T instance.");
+ }
+
+ [Fact]
+ public async Task GetSummary_Expands_Mixed_Tags()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Returns or if not found.");
+
+ var result = await provider.GetSummaryAsync(typeof(StubType));
+
+ result.ShouldBe("Returns String or null if key not found.");
+ }
+
+ [Fact]
+ public async Task GetRemarks_Returns_Null_When_No_Remarks_Element()
+ {
+ var typeName = typeof(StubType).FullName!.Replace('+', '.');
+ var provider = CreateProvider(
+ $@"Only summary.");
+
+ var result = await provider.GetRemarksAsync(typeof(StubType));
+
+ result.ShouldBeNull();
+ }
+
+ ///
+ /// A fake provider that loads XML from an in-memory string instead of the file system.
+ ///
+ [DisableConventionalRegistration]
+ private sealed class FakeXmlDocumentationProvider : XmlDocumentationProvider
+ {
+ private readonly XDocument _document;
+
+ public FakeXmlDocumentationProvider(string xml)
+ {
+ _document = XDocument.Parse(xml);
+ }
+
+ protected override Task LoadXmlDocumentationFromDiskAsync(Assembly assembly)
+ {
+ return Task.FromResult(_document);
+ }
+ }
+}