diff --git a/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs b/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs index 8a25e7c2c..ab5ae3185 100644 --- a/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs +++ b/src/Elastic.Markdown/Helpers/DocumentationObjectPoolProvider.cs @@ -28,7 +28,8 @@ public static string UseLlmMarkdownRenderer(BuildContext buildContext, try { action(subscription.LlmMarkdownRenderer, context); - return subscription.RentedStringBuilder!.ToString(); + var result = subscription.RentedStringBuilder!.ToString(); + return result.EnsureTrimmed(); } finally { diff --git a/src/Elastic.Markdown/Helpers/StringExtensions.cs b/src/Elastic.Markdown/Helpers/StringExtensions.cs new file mode 100644 index 000000000..3a48baa77 --- /dev/null +++ b/src/Elastic.Markdown/Helpers/StringExtensions.cs @@ -0,0 +1,21 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Helpers; + +internal static class StringExtensions +{ + /// + /// Ensures the string is trimmed of leading and trailing whitespace. + /// Only allocates a new string if trimming actually changes the content. + /// + /// The string to trim + /// The trimmed string, reusing the original if no changes needed + public static string EnsureTrimmed(this string value) + { + var span = value.AsSpan(); + var trimmed = span.Trim(); + return trimmed.Length != value.Length ? trimmed.ToString() : value; + } +} diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs index 0155ce0e0..0e3179bf3 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs @@ -28,7 +28,9 @@ public HtmlString RenderBlock() EnhancedCodeBlockHtmlRenderer.RenderCodeBlockLines(subscription.HtmlRenderer, EnhancedCodeBlock); var result = subscription.RentedStringBuilder?.ToString(); DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); - return new HtmlString(result); + return result == null + ? HtmlString.Empty + : new HtmlString(result.EnsureTrimmed()); } public HtmlString RenderLineWithCallouts(string content, int lineNumber) diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index 0b6e893d5..4c358bf06 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using Elastic.Documentation.AppliesTo; using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst.Comments; using Elastic.Markdown.Myst.Directives.AppliesTo; using Markdig.Helpers; @@ -20,8 +21,11 @@ public class EnhancedCodeBlockHtmlRenderer : HtmlObjectRenderer(RazorSlice slice, HtmlRenderer renderer) => - slice.RenderAsync(renderer.Writer).GetAwaiter().GetResult(); + private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer renderer) + { + var html = slice.RenderAsync().GetAwaiter().GetResult(); + _ = renderer.Write(html.EnsureTrimmed()); + } /// /// Renders the code block lines while also removing the common indentation level. diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index f5262cdba..a3a37d757 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using Elastic.Documentation.AppliesTo; using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Directives.Admonition; using Elastic.Markdown.Myst.Directives.AppliesSwitch; @@ -398,7 +399,9 @@ private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock bloc { var document = MarkdownParser.ParseMarkdownStringAsync(block.Build, block.Context, s, block.IncludeFrom, block.Context.YamlFrontMatter, MarkdownParser.Pipeline); var html = document.ToHtml(MarkdownParser.Pipeline); - return html; + + // Trim to ensure consistent whitespace + return html.EnsureTrimmed(); } }); var html = slice.RenderAsync().GetAwaiter().GetResult(); @@ -406,7 +409,11 @@ private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock bloc } [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] - private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer renderer) => slice.RenderAsync(renderer.Writer).GetAwaiter().GetResult(); + private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer renderer) + { + var html = slice.RenderAsync().GetAwaiter().GetResult(); + _ = renderer.Write(html.EnsureTrimmed()); + } [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] private static void RenderRazorSliceRawContent(RazorSlice slice, HtmlRenderer renderer, DirectiveBlock obj) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveViewModel.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveViewModel.cs index 8301c5c41..f835c3e1a 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveViewModel.cs @@ -18,6 +18,6 @@ public HtmlString RenderBlock() var result = subscription.RentedStringBuilder?.ToString(); DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); - return new HtmlString(result); + return result == null ? HtmlString.Empty : new HtmlString(result.EnsureTrimmed()); } } diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index 32bd7c3ed..5336e9d3f 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -81,6 +81,6 @@ public HtmlString RenderTitle() var result = subscription.RentedStringBuilder?.ToString() ?? Title; DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); - return new(result); + return new HtmlString(result.EnsureTrimmed()); } } diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 7ff7e7057..1d56005a2 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -86,9 +86,6 @@ public static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Mark return url; } } - - - } /// @@ -140,6 +137,7 @@ protected override void Write(LlmMarkdownRenderer renderer, EnhancedCodeBlock ob renderer.Write(obj.Caption); renderer.WriteLine(" -->"); } + renderer.Write("```"); if (!string.IsNullOrEmpty(obj.Language)) renderer.Write(obj.Language); @@ -151,6 +149,7 @@ protected override void Write(LlmMarkdownRenderer renderer, EnhancedCodeBlock ob renderer.Write(line.ToString()); renderer.WriteLine(); } + renderer.WriteLine("```"); } @@ -170,32 +169,35 @@ public class LlmListRenderer : MarkdownObjectRenderer()) + var items = listBlock.Cast().ToArray(); + foreach (var item in items) { renderer.Write(baseIndent); renderer.Write(isOrdered ? $"{itemIndex}. " : "- "); - foreach (var block in item) + for (var blockIndex = 0; blockIndex < item.Count; blockIndex++) { - if (block != item.First()) + var block = item[blockIndex]; + if (blockIndex > 0) { - var continuationIndent = GetContinuationIndent(baseIndent, isOrdered); - renderer.Write(continuationIndent); + renderer.EnsureLine(); + renderer.Write(GetContinuationIndent(baseIndent, isOrdered)); } - - if (block != item.First() && block is ListBlock) - renderer.WriteLine(); RenderBlockWithIndentation(renderer, block, baseIndent, isOrdered); } - renderer.EnsureLine(); if (isOrdered) itemIndex++; @@ -207,12 +209,22 @@ private static string GetContinuationIndent(string baseIndent, bool isOrdered) = private static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Block block, string baseIndent, bool isOrdered) { + // Nested lists render in same context to preserve indentation + if (block is ListBlock) + { + _ = renderer.Render(block); + return; + } + + // Render other blocks in separate context and re-indent each line var blockOutput = DocumentationObjectPoolProvider.UseLlmMarkdownRenderer(renderer.BuildContext, block, static (tmpRenderer, obj) => { _ = tmpRenderer.Render(obj); }); + var continuationIndent = GetContinuationIndent(baseIndent, isOrdered); var lines = blockOutput.Split('\n'); + for (var i = 0; i < lines.Length; i++) { var line = lines[i]; @@ -222,6 +234,7 @@ private static void RenderBlockWithIndentation(LlmMarkdownRenderer renderer, Blo { renderer.WriteLine(); renderer.Write(continuationIndent); + // Preserve exact code block formatting, trim other content renderer.Write(block is CodeBlock ? line : line.TrimStart()); } else if (i < lines.Length - 1) @@ -329,6 +342,7 @@ private static void RenderTableRowCells(LlmMarkdownRenderer renderer, TableRow r renderer.Writer.Write(" |"); cellIndex++; } + renderer.WriteLine(); } @@ -665,7 +679,6 @@ public class LlmDefinitionItemRenderer : MarkdownObjectRenderer().First(); renderer.EnsureBlockSpacing(); renderer.Write(""); } diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs index 025cc5ef2..22c9c4cf8 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/DocsSyncTests.cs @@ -306,6 +306,7 @@ public async Task TestApply() tagObjects.Should().Contain(t => t.Key == "docs.sync.files.added" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 3); tagObjects.Should().Contain(t => t.Key == "docs.sync.files.updated" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); tagObjects.Should().Contain(t => t.Key == "docs.sync.files.deleted" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); - tagObjects.Should().Contain(t => t.Key == "docs.sync.files.total" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 5); + tagObjects.Should().Contain(t => t.Key == "docs.sync.files.skipped" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 1); + tagObjects.Should().Contain(t => t.Key == "docs.sync.files.total" && Convert.ToInt64(t.Value, System.Globalization.CultureInfo.InvariantCulture) == 6); } } diff --git a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs index b6ce7fdf5..617cd849a 100644 --- a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs +++ b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs @@ -327,10 +327,9 @@ type ``directive in list should be indented correctly`` () = ```python def hello(): print("Hello, world!") -``` + ``` - List item 2 - - Nested list item 1 - Nested list item 2 @@ -355,7 +354,6 @@ This is where the content for tab #2 goes. let ``rendered correctly`` () = markdown |> convertsToNewLLM """ - This is where the content for tab #1 goes.