Skip to content

Commit 9bce37f

Browse files
robertmclawsclaude
andcommitted
Fix excessive whitespace in rendered summaries and remarks
- Fix <para> tag conversion to strip XML doc comment indentation using RemoveCommonIndentation, instead of preserving raw source formatting - Add ConsecutiveBlankLinesPattern to collapse 3+ newlines (from para conversion + inter-tag whitespace nodes) down to a single blank line - Add NormalizeXmlDocIndentation to handle the mixed-indent pattern from XML doc extraction, where the first line has zero indent (from ExtractInnerXml.Trim()) but continuation lines carry the XML file's structural indentation - Call NormalizeXmlDocIndentation before the XML tag fast-path check in ConvertXmlToMarkdown so plain multi-line text (e.g., summaries without <para> tags) also gets normalized - Add 5 regression tests covering: para indentation stripping, excessive blank lines between paras, para with embedded code blocks, the real-world AskUserQuestion payload, and multi-line summaries without XML tags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 89ac7ac commit 9bce37f

4 files changed

Lines changed: 267 additions & 4 deletions

File tree

src/CloudNimble.DotNetDocs.Core/Transformers/MarkdownXmlTransformer.cs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ public partial class MarkdownXmlTransformer : IDocTransformer
9696
[GeneratedRegex(@"<br\s*/?>", RegexOptions.IgnoreCase)]
9797
private static partial Regex LineBreakPattern();
9898

99+
/// <summary>
100+
/// Pattern for collapsing three or more consecutive newlines into exactly two.
101+
/// </summary>
102+
[GeneratedRegex(@"\n{3,}")]
103+
private static partial Regex ConsecutiveBlankLinesPattern();
104+
99105
/// <summary>
100106
/// Pattern for list structures.
101107
/// </summary>
@@ -291,6 +297,15 @@ protected virtual void AddToReferences(DocEntity entity, Dictionary<string, DocE
291297
return text;
292298
}
293299

300+
// Normalize indentation inherited from XML doc file formatting.
301+
// This must run before the XML tag check because plain multi-line text
302+
// (e.g., summaries without <para> tags) also carries this artifact.
303+
// ExtractInnerXml trims the overall string, which strips the first line's
304+
// indentation but leaves continuation lines with their XML file indentation.
305+
// RemoveCommonIndentation handles blocks where ALL lines share indentation;
306+
// NormalizeXmlDocIndentation handles the mixed case from extraction.
307+
text = NormalizeXmlDocIndentation(text);
308+
294309
// Quick check - skip processing if no XML tags or HTML tags present
295310
// We need to process content with HTML tags to apply noescape handling and escaping
296311
if (!HasXmlTags().IsMatch(text) && !text.Contains('<'))
@@ -446,6 +461,85 @@ internal static string RemoveCommonIndentation(string code)
446461
return result.ToString().Trim();
447462
}
448463

464+
/// <summary>
465+
/// Normalizes indentation artifacts from XML documentation file formatting.
466+
/// </summary>
467+
/// <remarks>
468+
/// <para>
469+
/// <c>ExtractInnerXml</c> trims the overall string, which strips the first line's
470+
/// leading whitespace but leaves continuation lines with their XML file indentation.
471+
/// <c>RemoveCommonIndentation</c> handles the uniform case (all lines indented);
472+
/// this method handles the mixed case where the first line has indent 0 but the
473+
/// rest share a common indent from the XML file structure.
474+
/// </para>
475+
/// </remarks>
476+
/// <param name="text">The text to normalize.</param>
477+
/// <returns>The text with XML file indentation removed from all lines.</returns>
478+
internal static string NormalizeXmlDocIndentation(string text)
479+
{
480+
if (string.IsNullOrWhiteSpace(text) || !text.Contains('\n'))
481+
return text;
482+
483+
var lines = text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
484+
485+
// Find the minimum indentation across non-empty lines that actually have indentation.
486+
// Lines with zero indent (e.g., the first line after ExtractInnerXml.Trim()) are excluded
487+
// from the calculation so they don't force the minimum to zero.
488+
var minIndent = int.MaxValue;
489+
foreach (var line in lines)
490+
{
491+
if (string.IsNullOrWhiteSpace(line))
492+
continue;
493+
494+
var indent = 0;
495+
foreach (var ch in line)
496+
{
497+
if (ch is ' ' or '\t')
498+
indent++;
499+
else
500+
break;
501+
}
502+
503+
// Only consider lines that actually have indentation
504+
if (indent > 0 && indent < minIndent)
505+
minIndent = indent;
506+
}
507+
508+
// No indented lines found, nothing to normalize
509+
if (minIndent == int.MaxValue)
510+
return text.Trim();
511+
512+
// Remove common indentation from indented lines, leave others as-is
513+
var result = new StringBuilder();
514+
for (var i = 0; i < lines.Length; i++)
515+
{
516+
var line = lines[i];
517+
if (!string.IsNullOrWhiteSpace(line) && line.Length >= minIndent)
518+
{
519+
var indent = 0;
520+
foreach (var ch in line)
521+
{
522+
if (ch is ' ' or '\t')
523+
indent++;
524+
else
525+
break;
526+
}
527+
528+
result.AppendLine(indent >= minIndent ? line[minIndent..] : line);
529+
}
530+
else if (string.IsNullOrWhiteSpace(line))
531+
{
532+
result.AppendLine();
533+
}
534+
else
535+
{
536+
result.AppendLine(line);
537+
}
538+
}
539+
540+
return result.ToString().Trim();
541+
}
542+
449543
/// <summary>
450544
/// Converts parameter and type parameter reference tags.
451545
/// </summary>
@@ -473,10 +567,10 @@ protected virtual string ConvertReferenceParams(string text)
473567
/// </summary>
474568
protected virtual string ConvertFormattingTags(string text)
475569
{
476-
// Convert <para></para> tags
570+
// Convert <para></para> tags, trimming indentation from XML doc comments
477571
text = ParaPattern().Replace(text, match =>
478572
{
479-
var content = match.Groups[1].Value;
573+
var content = RemoveCommonIndentation(match.Groups[1].Value);
480574
return $"\n\n{content}\n\n";
481575
});
482576

@@ -497,6 +591,10 @@ protected virtual string ConvertFormattingTags(string text)
497591
// Convert <br/> line break tags
498592
text = LineBreakPattern().Replace(text, " \n");
499593

594+
// Collapse runs of 3+ newlines (from para conversion + inter-tag whitespace) to at most one blank line,
595+
// then trim leading/trailing whitespace left over from XML doc comment formatting
596+
text = ConsecutiveBlankLinesPattern().Replace(text, "\n\n").Trim();
597+
500598
return text;
501599
}
502600

src/CloudNimble.DotNetDocs.Docs/CloudNimble.DotNetDocs.Docs.docsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="DotNetDocs.Sdk/1.5.1">
1+
<Project Sdk="DotNetDocs.Sdk/1.5.3">
22

33
<PropertyGroup>
44
<DocumentationType>Mintlify</DocumentationType>

src/CloudNimble.DotNetDocs.Reference.Mintlify/CloudNimble.DotNetDocs.Reference.Mintlify.docsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="DotNetDocs.Sdk/1.4.1">
1+
<Project Sdk="DotNetDocs.Sdk/1.5.3">
22
<PropertyGroup>
33
<DocumentationType>Mintlify</DocumentationType>
44
<GenerateMintlifyDocs>true</GenerateMintlifyDocs>

src/CloudNimble.DotNetDocs.Tests.Core/Transformers/MarkdownXmlTransformerTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,171 @@ This is <b>bold with <i>italic inside</i></b> text.
11021102
testType.Summary.Should().Contain("**bold with *italic inside***");
11031103
}
11041104

1105+
[TestMethod]
1106+
public async Task TransformAsync_ParaContent_StripsXmlDocCommentIndentation()
1107+
{
1108+
var assemblyPath = typeof(NamespaceMode).Assembly.Location;
1109+
var xmlPath = System.IO.Path.ChangeExtension(assemblyPath, ".xml");
1110+
using var manager = new AssemblyManager(assemblyPath, xmlPath);
1111+
var assembly = await manager.DocumentAsync();
1112+
1113+
var testType = assembly.Namespaces
1114+
.SelectMany(n => n.Types)
1115+
.First();
1116+
1117+
// Simulates what ExtractInnerXml produces from typical XML doc comments
1118+
// where <para> content is indented 12 spaces (3 levels of 4-space indent)
1119+
testType.Remarks = """
1120+
<para>
1121+
Each question includes a prompt, a short header label, a set of options to choose from,
1122+
and whether multiple selections are allowed. Questions should be clear, specific, and
1123+
end with a question mark.
1124+
</para>
1125+
""";
1126+
1127+
await _transformer.TransformAsync(testType);
1128+
1129+
testType.Remarks.Should().NotBeNull();
1130+
testType.Remarks.Should().NotContain("<para>");
1131+
// Indentation from C# source should be stripped
1132+
testType.Remarks.Should().NotStartWith(" ");
1133+
testType.Remarks.Should().NotContain("\n Each");
1134+
testType.Remarks.Should().StartWith("Each question includes");
1135+
}
1136+
1137+
[TestMethod]
1138+
public async Task TransformAsync_MultipleParaTags_DoesNotProduceExcessiveBlankLines()
1139+
{
1140+
var assemblyPath = typeof(NamespaceMode).Assembly.Location;
1141+
var xmlPath = System.IO.Path.ChangeExtension(assemblyPath, ".xml");
1142+
using var manager = new AssemblyManager(assemblyPath, xmlPath);
1143+
var assembly = await manager.DocumentAsync();
1144+
1145+
var testType = assembly.Namespaces
1146+
.SelectMany(n => n.Types)
1147+
.First();
1148+
1149+
// Simulates ExtractInnerXml output from remarks with multiple <para> tags,
1150+
// including inter-tag whitespace nodes (newlines + indentation between </para> and <para>)
1151+
testType.Remarks =
1152+
"<para>\n First paragraph content.\n </para>\n \n <para>\n Second paragraph content.\n </para>";
1153+
1154+
await _transformer.TransformAsync(testType);
1155+
1156+
testType.Remarks.Should().NotBeNull();
1157+
testType.Remarks.Should().NotContain("<para>");
1158+
// There should be at most one blank line (two consecutive newlines) between paragraphs
1159+
testType.Remarks.Should().NotContain("\n\n\n");
1160+
testType.Remarks.Should().Contain("First paragraph content.");
1161+
testType.Remarks.Should().Contain("Second paragraph content.");
1162+
}
1163+
1164+
[TestMethod]
1165+
public async Task TransformAsync_ParaWithCodeBlock_PreservesCodeAndStripsParaIndentation()
1166+
{
1167+
var assemblyPath = typeof(NamespaceMode).Assembly.Location;
1168+
var xmlPath = System.IO.Path.ChangeExtension(assemblyPath, ".xml");
1169+
using var manager = new AssemblyManager(assemblyPath, xmlPath);
1170+
var assembly = await manager.DocumentAsync();
1171+
1172+
var testType = assembly.Namespaces
1173+
.SelectMany(n => n.Types)
1174+
.First();
1175+
1176+
// Simulates a remarks block with a para containing a code example,
1177+
// as seen in the AskUserQuestion class
1178+
testType.Remarks =
1179+
"<para>\n Each question includes a prompt.\n </para>\n \n <para>\n Example JSON:\n <code>\n{\n \"question\": \"Which library?\"\n}\n</code>\n </para>";
1180+
1181+
await _transformer.TransformAsync(testType);
1182+
1183+
testType.Remarks.Should().NotBeNull();
1184+
testType.Remarks.Should().NotContain("<para>");
1185+
testType.Remarks.Should().NotContain("\n\n\n");
1186+
testType.Remarks.Should().Contain("Each question includes a prompt.");
1187+
testType.Remarks.Should().Contain("Example JSON:");
1188+
testType.Remarks.Should().Contain("```csharp");
1189+
// Output should not have leading whitespace on content lines
1190+
testType.Remarks.Should().NotStartWith(" ");
1191+
}
1192+
1193+
[TestMethod]
1194+
public async Task TransformAsync_RealWorldRemarksPayload_ProducesCleanOutput()
1195+
{
1196+
var assemblyPath = typeof(NamespaceMode).Assembly.Location;
1197+
var xmlPath = System.IO.Path.ChangeExtension(assemblyPath, ".xml");
1198+
using var manager = new AssemblyManager(assemblyPath, xmlPath);
1199+
var assembly = await manager.DocumentAsync();
1200+
1201+
var testType = assembly.Namespaces
1202+
.SelectMany(n => n.Types)
1203+
.First();
1204+
1205+
// Exact payload that ExtractInnerXmlExcluding produces from AskUserQuestion.cs remarks
1206+
testType.Remarks = """
1207+
<para>
1208+
Each question includes a prompt, a short header label, a set of options to choose from,
1209+
and whether multiple selections are allowed. Questions should be clear, specific, and
1210+
end with a question mark.
1211+
</para>
1212+
1213+
<para>
1214+
Example JSON:
1215+
<code>
1216+
{
1217+
"question": "Which library should we use for date formatting?",
1218+
"header": "Library",
1219+
"options": [
1220+
{ "label": "date-fns", "description": "Lightweight, tree-shakeable date utility library" },
1221+
{ "label": "Day.js", "description": "2KB immutable date library, Moment.js alternative" }
1222+
],
1223+
"multiSelect": false
1224+
}
1225+
</code>
1226+
</para>
1227+
""";
1228+
1229+
await _transformer.TransformAsync(testType);
1230+
1231+
testType.Remarks.Should().NotBeNull();
1232+
testType.Remarks.Should().NotContain("<para>");
1233+
testType.Remarks.Should().NotContain("<code>");
1234+
// Must not have 3+ consecutive newlines anywhere
1235+
testType.Remarks.Should().NotContain("\n\n\n");
1236+
// Must not have leading whitespace
1237+
testType.Remarks.Should().NotStartWith(" ");
1238+
testType.Remarks.Should().NotStartWith("\n");
1239+
// Content should be present and clean
1240+
testType.Remarks.Should().Contain("Each question includes a prompt");
1241+
testType.Remarks.Should().Contain("Example JSON:");
1242+
testType.Remarks.Should().Contain("```csharp");
1243+
}
1244+
1245+
[TestMethod]
1246+
public async Task TransformAsync_MultiLineSummary_StripsXmlFileIndentation()
1247+
{
1248+
var assemblyPath = typeof(NamespaceMode).Assembly.Location;
1249+
var xmlPath = System.IO.Path.ChangeExtension(assemblyPath, ".xml");
1250+
using var manager = new AssemblyManager(assemblyPath, xmlPath);
1251+
var assembly = await manager.DocumentAsync();
1252+
1253+
var testType = assembly.Namespaces
1254+
.SelectMany(n => n.Types)
1255+
.First();
1256+
1257+
// Simulates what ExtractInnerXml produces from a multi-line <summary> with no XML tags.
1258+
// The 12-space indent on the second line is an artifact of the XML file formatting.
1259+
testType.Summary = "Represents the input parameters for the AskUserQuestion tool.\n The AskUserQuestion tool presents structured multiple-choice questions to the user.";
1260+
1261+
await _transformer.TransformAsync(testType);
1262+
1263+
testType.Summary.Should().NotBeNull();
1264+
// The XML file indentation should be stripped
1265+
testType.Summary.Should().NotContain("\n The");
1266+
testType.Summary.Should().Contain("Represents the input parameters");
1267+
testType.Summary.Should().Contain("The AskUserQuestion tool presents");
1268+
}
1269+
11051270
#endregion
11061271

11071272
#region Escape Remaining XML Tags Tests

0 commit comments

Comments
 (0)