Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<VersionPrefix>10.2.2</VersionPrefix>
<VersionPrefix>10.2.3</VersionPrefix>
<AssemblyVersion>10.2.0.0</AssemblyVersion>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<RootNamespace>Asp.Versioning.OpenApi</RootNamespace>
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Fixed XML Comment whitespace handling [Issue #1205](https://github.com/dotnet/aspnet-api-versioning/issues/1205)
Fixed XML Comment list and link handling [Issue #1205](https://github.com/dotnet/aspnet-api-versioning/issues/1205)
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
namespace Asp.Versioning.OpenApi.Transformers;

using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Xml.Linq;
using static System.Globalization.CultureInfo;
using static System.Reflection.BindingFlags;

/// <summary>
Expand All @@ -18,6 +18,10 @@ namespace Asp.Versioning.OpenApi.Transformers;
public class XmlComments
{
private const int MaxInheritDocDepth = 8;
private static readonly CompositeFormat ItemOfFormat = CompositeFormat.Parse( "**{0}**: {1}" );
private static readonly CompositeFormat LinkFormat = CompositeFormat.Parse( "[{0}]({1})" );
private static readonly CompositeFormat BlockFormat = CompositeFormat.Parse( "\n{0}\n" );
private static readonly CompositeFormat FenceBlockFormat = CompositeFormat.Parse( "\n{0}\n{1}\n{0}\n" );
private readonly ConcurrentDictionary<string, XElement?> members = new();

/// <summary>
Expand Down Expand Up @@ -176,7 +180,7 @@ public virtual bool IsParameterDeprecated( MemberInfo member, string name )
/// <a href="https://learn.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle">tutorial</a>
/// for more information.</remarks>
public virtual string GetResponseDescription( MemberInfo member, int statusCode )
=> GetResponseDescription( member, statusCode.ToString( CultureInfo.InvariantCulture ) );
=> GetResponseDescription( member, statusCode.ToString( InvariantCulture ) );

/// <summary>
/// Gets the <c>response</c> description from the specified member, if any.
Expand Down Expand Up @@ -454,8 +458,12 @@ private static void ResolveListTags( XElement element )
_ => BulletedList.Bullets(),
};
var items = list.Elements( "item" ).Select( item => bullets.Next() + ItemOf( item ) );
var block = string.Format( InvariantCulture, BlockFormat, string.Join( '\n', items ) );

list.ReplaceWith( new XText( string.Join( "\n", items ) ) );
// a list is a block, so like a table it is surrounded by blank lines. the line that follows the last
// item would otherwise be a lazy continuation of it and the text written after the list would be
// pulled into it; the line that precedes the first item would be the paragraph the list interrupts.
list.ReplaceWith( new XText( block ) );
}
}

Expand Down Expand Up @@ -483,7 +491,7 @@ private static string ItemOf( XElement item )
return text;
}

return text.Length == 0 ? name : "**" + name + "**: " + text;
return text.Length == 0 ? name : string.Format( InvariantCulture, ItemOfFormat, name, text );
}

// a table is the one list type with a markdown equivalent that is not a list. it only resolves to a table when
Expand Down Expand Up @@ -554,7 +562,14 @@ private static void AppendRow( StringBuilder table, List<string> cells, int colu
{
var cell = i < cells.Count ? cells[i] : string.Empty;

table.Append( cell.Length == 0 ? " " : " " + cell + " " ).Append( '|' );
table.Append( ' ' );

if ( cell.Length > 0 )
{
table.Append( cell ).Append( ' ' );
}

table.Append( '|' );
}

table.Append( '\n' );
Expand All @@ -570,8 +585,9 @@ private static void ResolveCodeBlocks( XElement element )
{
var content = TrimEachLine( code.Value );
var fence = FenceFor( content );
var block = new XText( string.Format( InvariantCulture, FenceBlockFormat, fence, content ) );

code.ReplaceWith( new XText( "\n" + fence + "\n" + content + "\n" + fence + "\n" ) );
code.ReplaceWith( block );
}
}

Expand Down Expand Up @@ -602,7 +618,8 @@ private static void ResolveInlineCode( XElement element )
}

// <b>, <i>, and <a> are the html tags a documentation comment carries inline, and each has a direct markdown
// equivalent. rewriting them keeps the meaning that reading the text of the enclosing element would drop.
// equivalent. <see /> and <seealso /> join them in the one form that is a link rather than a reference to a
// code element. rewriting them keeps the meaning that reading the text of the enclosing element would drop.
// the tags are visited from the inside out so that one nested in another is rewritten before it is absorbed.
//
// emphasis is delimited by an asterisk rather than an underscore. the two are interchangeable on their own,
Expand All @@ -618,6 +635,7 @@ private static void ResolveInlineTags( XElement element )
"b" => Delimit( inline.Value, "**" ),
"i" => Delimit( inline.Value, "*" ),
"a" => LinkOf( inline ),
"see" or "seealso" => SeeOf( inline ),
_ => default,
};

Expand All @@ -628,8 +646,19 @@ private static void ResolveInlineTags( XElement element )
}
}

// a link with no text renders as its own address, which is all there is to show. a link with no address is
// not a link at all, so only the text it wrapped is kept.
// <see href="" /> and <seealso href="" /> are links written with the tags a documentation comment already uses
// for a reference; they are the form the documentation generators accept as an alternative to <a />, so they
// resolve the same way. both are inline where they are written, so the address belongs in the sentence around
// it; only the tooling that renders a separate "see also" section treats <seealso /> as a block of its own.
//
// every other form names a code element instead of an address - <see cref="" /> and <see langword="" /> have
// no markdown of their own - and is left in the tree for the text it wraps, if any, to be read in place.
private static string? SeeOf( XElement see ) => see.Attribute( "href" ) is null ? default : LinkOf( see );

// a link with no text has nothing to show but its own address, so the address becomes the text. writing the
// address on its own would rely on the renderer turning it into a link, which is an extension that not every
// renderer implements; Scalar does and Swagger UI does not. a link with no address is not a link at all, so
// only the text it wrapped is kept.
private static string LinkOf( XElement anchor )
{
var text = Flatten( anchor.Value );
Expand All @@ -640,7 +669,12 @@ private static string LinkOf( XElement anchor )
return text;
}

return text.Length == 0 ? href : "[" + text + "](" + href + ")";
if ( string.IsNullOrWhiteSpace( text ) )
{
text = href;
}

return string.Format( InvariantCulture, LinkFormat, text, href );
}

// a span occupies a single line and its delimiters cannot be padded by whitespace, so the content is
Expand Down Expand Up @@ -817,7 +851,7 @@ private sealed class BulletedList

private BulletedList( Func<int, string> generate ) => this.generate = generate;

private static string Increment( int number ) => number.ToString( CultureInfo.InvariantCulture ) + ". ";
private static string Increment( int number ) => number.ToString( InvariantCulture ) + ". ";

private static string Bullet( int number ) => "* ";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,51 @@ public class Documented
/// Gets or sets the site, which is <a href="https://example.com" />.
/// </summary>
public string Site { get; set; }

/// <summary>
/// Gets or sets the spec, which is described by <a href="https://example.com/spec"></a>.
/// </summary>
public string Spec { get; set; }

/// <summary>
/// Gets or sets the anonymous, which is described by <a>the specification</a>.
/// </summary>
public string Anonymous { get; set; }

/// <summary>
/// Gets or sets the manual, which is described by <see href="https://example.com">the
/// specification</see>.
/// </summary>
public string Manual { get; set; }

/// <summary>
/// Gets or sets the guide, which is described by <see href="https://example.com/guide" />.
/// </summary>
public string Guide { get; set; }

/// <summary>
/// Gets or sets the cited. <see cref="Status" />
/// </summary>
public string Cited { get; set; }

/// <summary>
/// Gets or sets the related, which is described by <seealso href="https://example.com/related">the related
/// specification</seealso>.
/// </summary>
public string Related { get; set; }

/// <summary>
/// Gets or sets the referred. <seealso cref="Status" />
/// </summary>
public string Referred { get; set; }

/// <summary>
/// Gets or sets the outline.
/// <list type="number">
/// <item><description>First step</description></item>
/// <item><description>Second step</description></item>
/// </list>
/// Text after list
/// </summary>
public string Outline { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ public static class MinimalApi
/// <returns>The outlined answer.</returns>
public static int Outlined() => 42;

/// <summary>Stepped</summary>
/// <remarks>
/// Text before list
/// <list type="number">
/// <item><description>First step</description></item>
/// <item><description>Second step</description></item>
/// </list>
/// Text after list
/// </remarks>
/// <returns>The stepped answer.</returns>
public static int Stepped() => 42;

/// <summary>Linked</summary>
/// <remarks>
/// <a href="https://example.org/spec"></a>
///
/// <a href="https://example.org/spec" />
/// </remarks>
/// <returns>The linked answer.</returns>
public static int Linked() => 42;

/// <summary>
/// Echo
/// </summary>
Expand Down
Loading
Loading