diff --git a/NOTICE.txt b/NOTICE.txt index 729c8f46d..9547245cf 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -555,5 +555,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - diff --git a/docs/syntax/code.md b/docs/syntax/code.md index 4cb8e8503..82d7f7bb3 100644 --- a/docs/syntax/code.md +++ b/docs/syntax/code.md @@ -267,10 +267,6 @@ project: ### Console code blocks -:::{note} -This feature is still being developed. -::: - We document a lot of API endpoints at Elastic. For these endpoints, we support `console` as a language. The term console relates to the dev console in kibana which users can link to directly from these code snippets. In a console code block, the first line is highlighted as a dev console string and the remainder as json: @@ -309,6 +305,54 @@ GET /mydocuments/_search :::: +Console code blocks now support multiple API calls within a single code block. When you have multiple console commands, they are displayed as separate sections within the same block with proper visual separation: + +::::{tab-set} + +:::{tab-item} Output + +```console +GET /mydocuments/_search +{ + "from": 1, + "query": { + "match_all" {} + } +} + +POST /mydocuments/_doc +{ + "title": "New Document", + "content": "This is a sample document" +} +``` + +::: + +:::{tab-item} Markdown + +````markdown +```console +GET /mydocuments/_search +{ + "from": 1, + "query": { + "match_all" {} + } +} + +POST /mydocuments/_doc +{ + "title": "New Document", + "content": "This is a sample document" +} +``` +```` + +::: + +:::: + ### Code block substitutions You can use substitutions to insert reusable values into your code block examples. diff --git a/docs/testing/index.md b/docs/testing/index.md index d17fecb60..0319fce2d 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -31,3 +31,70 @@ The files in this directory are used for testing purposes. Do not edit these fil "key": "value" } ``` + +```console +PUT metricbeat-2016.05.30/_doc/1?refresh <1> +{"system.cpu.idle.pct": 0.908} +PUT metricbeat-2016.05.31/_doc/1?refresh <2> +{"system.cpu.idle.pct": 0.105} +``` +1. test 1 +2. test 2 + +```console +POST _reindex +{ + "max_docs": 10, + "source": { + "index": "my-index-000001", + "query": { + "function_score" : { + "random_score" : {}, + "min_score" : 0.9 + } + } + }, + "dest": { + "index": "my-new-index-000001" + } +} +``` + +```console +GET metricbeat-2016.05.30-1/_doc/1 +GET metricbeat-2016.05.31-1/_doc/1 +``` + +```console +PUT my-index-000001 +{ + "mappings": { + "enabled": false <1> + } +} + +PUT my-index-000001/_doc/session_1 +{ + "user_id": "kimchy", + "session_data": { + "arbitrary_object": { + "some_array": [ "foo", "bar", { "baz": 2 } ] + } + }, + "last_updated": "2015-12-06T18:20:22" +} + +GET my-index-000001/_doc/session_1 <2> + +GET my-index-000001/_mapping <3> +``` + +1. The entire mapping is disabled. +2. The document can be retrieved. +3. Checking the mapping reveals that no fields have been added. + +```javascript +const foo = "bar"; <1> +``` + +1. This is a JavaScript code block. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/code.css b/src/Elastic.Documentation.Site/Assets/markdown/code.css index 62652d91b..0871aa1b7 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/code.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/code.css @@ -19,8 +19,13 @@ code:last-child { @apply rounded-b-sm; } - code.language-apiheader { - @apply border-b-grey-80 border-b-1; + code.language-apiheader + code.language-json { + @apply -mt-6 pt-0!; + } + + code.language-json + code.language-apiheader, + code.language-apiheader + code.language-apiheader { + @apply border-t-grey-100 border-t-1 border-dotted; } } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/Code.cshtml b/src/Elastic.Markdown/Myst/CodeBlocks/Code.cshtml index 5c826fbe8..ddefcf943 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/Code.cshtml +++ b/src/Elastic.Markdown/Myst/CodeBlocks/Code.cshtml @@ -8,6 +8,22 @@ ΒΆ } -
@if (!string.IsNullOrEmpty(Model.ApiCallHeader)) { @Model.ApiCallHeader }@(Model.RenderBlock())
+
+		@if (Model.ApiSegments.Count > 0)
+		{
+			@foreach (var segment in Model.ApiSegments)
+			{
+				@(Model.RenderLineWithCallouts(segment.Header, segment.LineNumber))
+				@if (segment.ContentLinesWithNumbers.Count > 0)
+				{
+					@(Model.RenderContentLinesWithCallouts(segment.ContentLinesWithNumbers))
+				}
+			}
+		}
+		else
+		{
+			@(Model.RenderBlock())
+		}
+		
diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs index d0393529c..44323e3f5 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/CodeViewModel.cs @@ -9,7 +9,7 @@ namespace Elastic.Markdown.Myst.CodeBlocks; public class CodeViewModel { - public required string? ApiCallHeader { get; init; } + public required List ApiSegments { get; init; } public required string? Caption { get; init; } public required string Language { get; init; } public required string? CrossReferenceName { get; init; } @@ -29,4 +29,53 @@ public HtmlString RenderBlock() DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); return new HtmlString(result); } + + public HtmlString RenderLineWithCallouts(string content, int lineNumber) + { + if (EnhancedCodeBlock?.CallOuts == null) + return new HtmlString(content); + + var callouts = EnhancedCodeBlock.CallOuts.Where(c => c.Line == lineNumber); + if (!callouts.Any()) + return new HtmlString(content); + + var line = content; + var html = new System.Text.StringBuilder(); + + // Remove callout markers from the line + foreach (var callout in callouts) + { + var calloutPattern = $"<{callout.Index}>"; + line = line.Replace(calloutPattern, ""); + } + line = line.TrimEnd(); + + _ = html.Append(line); + + // Add callout HTML after the line + foreach (var callout in callouts) + { + _ = html.Append($""); + } + + return new HtmlString(html.ToString()); + } + + public HtmlString RenderContentLinesWithCallouts(List<(string Content, int LineNumber)> contentLinesWithNumbers) + { + if (contentLinesWithNumbers.Count == 0) + return HtmlString.Empty; + + var html = new System.Text.StringBuilder(); + for (var i = 0; i < contentLinesWithNumbers.Count; i++) + { + var (content, lineNumber) = contentLinesWithNumbers[i]; + + if (i > 0) + _ = html.Append('\n'); + + _ = html.Append(RenderLineWithCallouts(content, lineNumber)); + } + return new HtmlString(html.ToString()); + } } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs index 4abd653d7..ffc8aae5b 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -10,6 +10,14 @@ namespace Elastic.Markdown.Myst.CodeBlocks; +public class ApiSegment +{ + public string Header { get; set; } = ""; + public List ContentLines { get; set; } = []; + public int LineNumber { get; set; } + public List<(string Content, int LineNumber)> ContentLinesWithNumbers { get; set; } = []; +} + public class EnhancedCodeBlock(BlockParser parser, ParserContext context) : FencedCodeBlock(parser), IBlockExtension { @@ -31,5 +39,5 @@ public class EnhancedCodeBlock(BlockParser parser, ParserContext context) public string? Caption { get; set; } - public string? ApiCallHeader { get; set; } + public List ApiSegments { get; set; } = []; } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index 731635ca4..0b6e893d5 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -128,7 +128,7 @@ protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) CrossReferenceName = string.Empty,// block.CrossReferenceName, Language = block.Language, Caption = block.Caption, - ApiCallHeader = block.ApiCallHeader, + ApiSegments = block.ApiSegments, EnhancedCodeBlock = block }); diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs index 8b34aca0f..63e91b4ee 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs @@ -164,19 +164,19 @@ private static void ProcessCodeBlock( else codeBlockArgs = codeArgs; + // Process console blocks with multiple API segments + if (language == "console") + { + ProcessConsoleCodeBlock(lines, codeBlock, codeBlockArgs, context); + return; + } + var callOutIndex = 0; var originatingLine = 0; for (var index = 0; index < lines.Lines.Length; index++) { originatingLine++; var line = lines.Lines[index]; - if (index == 0 && language == "console") - { - codeBlock.ApiCallHeader = line.ToString(); - var s = new StringSlice(""); - lines.Lines[index] = new StringLine(ref s); - continue; - } var span = line.Slice.AsSpan(); if (codeBlockArgs.UseSubstitutions) @@ -200,58 +200,11 @@ private static void ProcessCodeBlock( continue; if (codeBlockArgs.UseCallouts) - { - List callOuts = []; - var hasClassicCallout = span.IndexOf("<") > 0 && span.LastIndexOf(">") == span.Length - 1; - if (hasClassicCallout) - { - var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span); - callOuts.AddRange( - EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false) - ); - } - - // only support magic callouts for smaller line lengths - if (callOuts.Count == 0 && span.Length < 200) - { - var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span); - callOuts.AddRange( - EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true) - ); - } - - codeBlock.CallOuts.AddRange(callOuts); - } - } - - //update string slices to ignore call outs - if (codeBlock.CallOuts.Count > 0) - { - var callouts = codeBlock.CallOuts.Aggregate(new Dictionary(), (acc, curr) => - { - if (acc.TryAdd(curr.Line, curr)) - return acc; - if (acc[curr.Line].SliceStart > curr.SliceStart) - acc[curr.Line] = curr; - return acc; - }); - - foreach (var callout in callouts.Values) - { - var line = lines.Lines[callout.Line - 1]; - var newSpan = line.Slice.AsSpan()[..callout.SliceStart]; - var s = new StringSlice(newSpan.ToString()); - lines.Lines[callout.Line - 1] = new StringLine(ref s); - } + ProcessCalloutsForLine(span, codeBlock, ref callOutIndex, originatingLine); } - var inlineAnnotations = codeBlock.CallOuts.Count(c => c.InlineCodeAnnotation); - var classicAnnotations = codeBlock.CallOuts.Count - inlineAnnotations; - if (inlineAnnotations > 0 && classicAnnotations > 0) - codeBlock.EmitError("Both inline and classic callouts are not supported"); - - if (inlineAnnotations > 0) - codeBlock.InlineAnnotations = true; + ProcessCalloutPostProcessing(lines, codeBlock); + ProcessInlineAnnotations(codeBlock); } private static List EnumerateAnnotations(Regex.ValueMatchEnumerator matches, @@ -335,4 +288,179 @@ private static List ParseClassicCallOuts(ValueMatch match, ref ReadOnly return callOuts; } + + private static void ProcessConsoleCodeBlock( + StringLineGroup lines, + EnhancedCodeBlock codeBlock, + CodeBlockArguments codeBlockArgs, + ParserContext context) + { + var currentSegment = new ApiSegment(); + var callOutIndex = 0; + var originatingLine = 0; + + for (var index = 0; index < lines.Lines.Length; index++) + { + originatingLine++; + var line = lines.Lines[index]; + var lineText = line.ToString(); + var span = line.Slice.AsSpan(); + + // Apply substitutions if enabled + if (codeBlockArgs.UseSubstitutions) + { + if (span.ReplaceSubstitutions(context.YamlFrontMatter?.Properties, context.Build.Collector, out var frontMatterReplacement)) + { + var s = new StringSlice(frontMatterReplacement); + lines.Lines[index] = new StringLine(ref s); + span = lines.Lines[index].Slice.AsSpan(); + lineText = frontMatterReplacement; + } + + if (span.ReplaceSubstitutions(context.Substitutions, context.Build.Collector, out var globalReplacement)) + { + var s = new StringSlice(globalReplacement); + lines.Lines[index] = new StringLine(ref s); + span = lines.Lines[index].Slice.AsSpan(); + lineText = globalReplacement; + } + } + + // Check if this line is an HTTP verb (API call header) + if (IsHttpVerb(lineText)) + { + if (!string.IsNullOrEmpty(currentSegment.Header) || currentSegment.ContentLines.Count > 0) + codeBlock.ApiSegments.Add(currentSegment); + + // Process callouts before creating the segment to capture them on the original line + if (codeBlockArgs.UseCallouts && codeBlock.OpeningFencedCharCount <= 3) + ProcessCalloutsForLine(span, codeBlock, ref callOutIndex, originatingLine); + + currentSegment = new ApiSegment + { + Header = lineText, + LineNumber = originatingLine + }; + + // Clear this line from the content since it's now a header + var s = new StringSlice(""); + lines.Lines[index] = new StringLine(ref s); + } + else + { + if (!string.IsNullOrEmpty(lineText.Trim())) + { + currentSegment.ContentLines.Add(lineText); + currentSegment.ContentLinesWithNumbers.Add((lineText, originatingLine)); + } + + if (codeBlockArgs.UseCallouts && codeBlock.OpeningFencedCharCount <= 3) + ProcessCalloutsForLine(span, codeBlock, ref callOutIndex, originatingLine); + } + } + + // Add the last segment if it has content + if (!string.IsNullOrEmpty(currentSegment.Header) || currentSegment.ContentLines.Count > 0) + codeBlock.ApiSegments.Add(currentSegment); + + ProcessCalloutPostProcessing(lines, codeBlock); + ProcessInlineAnnotations(codeBlock); + } + + private static bool IsHttpVerb(string line) + { + var trimmed = line.Trim(); + return trimmed.StartsWith("GET ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("POST ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("PUT ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("DELETE ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("PATCH ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("HEAD ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("OPTIONS ", StringComparison.OrdinalIgnoreCase); + } + + private static void ProcessCalloutsForLine(ReadOnlySpan span, EnhancedCodeBlock codeBlock, ref int callOutIndex, int originatingLine) + { + List callOuts = []; + var hasClassicCallout = span.IndexOf("<") > 0 && span.LastIndexOf(">") == span.Length - 1; + if (hasClassicCallout) + { + var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span); + callOuts.AddRange( + EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false) + ); + } + + // only support magic callouts for smaller line lengths + if (callOuts.Count == 0 && span.Length < 200) + { + var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span); + callOuts.AddRange( + EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true) + ); + } + + codeBlock.CallOuts.AddRange(callOuts); + } + + private static void ProcessCalloutPostProcessing(StringLineGroup lines, EnhancedCodeBlock codeBlock) + { + //update string slices to ignore call outs + if (codeBlock.CallOuts.Count > 0) + { + var callouts = codeBlock.CallOuts.Aggregate(new Dictionary(), (acc, curr) => + { + if (acc.TryAdd(curr.Line, curr)) + return acc; + if (acc[curr.Line].SliceStart > curr.SliceStart) + acc[curr.Line] = curr; + return acc; + }); + + // Console code blocks use ApiSegments for rendering, so we need to update headers directly + // Note: console language gets converted to "json" for syntax highlighting + if ((codeBlock.Language == "json" || codeBlock.Language == "console") && codeBlock.ApiSegments.Count > 0) + { + foreach (var callout in callouts.Values) + { + foreach (var segment in codeBlock.ApiSegments) + { + var calloutPattern = $"<{callout.Index}>"; + if (segment.Header.Contains(calloutPattern)) + { + segment.Header = segment.Header.Replace(calloutPattern, "").Trim(); + break; + } + } + } + } + else + { + foreach (var callout in callouts.Values) + { + var line = lines.Lines[callout.Line - 1]; + var span = line.Slice.AsSpan(); + + // Skip callouts on cleared lines to avoid ArgumentOutOfRangeException + if (span.Length == 0 || callout.SliceStart >= span.Length) + continue; + + var newSpan = span[..callout.SliceStart]; + var s = new StringSlice(newSpan.ToString()); + lines.Lines[callout.Line - 1] = new StringLine(ref s); + } + } + } + } + + private static void ProcessInlineAnnotations(EnhancedCodeBlock codeBlock) + { + var inlineAnnotations = codeBlock.CallOuts.Count(c => c.InlineCodeAnnotation); + var classicAnnotations = codeBlock.CallOuts.Count - inlineAnnotations; + if (inlineAnnotations > 0 && classicAnnotations > 0) + codeBlock.EmitError("Both inline and classic callouts are not supported"); + + if (inlineAnnotations > 0) + codeBlock.InlineAnnotations = true; + } } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index ef50735ab..773bf03aa 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -133,3 +133,4 @@ private IEnumerable ProcessApplicabilityCollection( }); } + diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 1e890eb6b..b17629f3e 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -283,7 +283,7 @@ private static void WriteLiteralIncludeBlock(HtmlRenderer renderer, IncludeBlock CrossReferenceName = null, Language = block.Language, Caption = null, - ApiCallHeader = null, + ApiSegments = [], RawIncludedFileContents = content }); RenderRazorSlice(slice, renderer); diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs new file mode 100644 index 000000000..5b9c122b3 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/ConsoleCodeBlockTests.cs @@ -0,0 +1,348 @@ +// 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 + +using Elastic.Markdown.Myst.CodeBlocks; +using Elastic.Markdown.Tests.Inline; +using FluentAssertions; +using JetBrains.Annotations; + +namespace Elastic.Markdown.Tests.CodeBlocks; + +public abstract class ConsoleCodeBlockTests( + ITestOutputHelper output, + [LanguageInjection("markdown")] string markdown +) + : BlockTest(output, markdown) +{ + [Fact] + public void ParsesConsoleCodeBlock() => Block.Should().NotBeNull(); + + [Fact] + public void SetsLanguage() => Block!.Language.Should().Be("json"); +} + +public class SingleConsoleApiCallTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /mydocuments/_search +{ + "from": 1, + "query": { + "match_all" {} + } +} +``` +""" +) +{ + [Fact] + public void CreatesSingleApiSegment() + { + Block!.ApiSegments.Should().HaveCount(1); + var segment = Block.ApiSegments[0]; + segment.Header.Should().Be("GET /mydocuments/_search"); + segment.ContentLines.Should().HaveCount(6); + segment.ContentLines[0].Should().Be("{"); + segment.ContentLines[1].Should().Be(" \"from\": 1,"); + segment.ContentLines[2].Should().Be(" \"query\": {"); + segment.ContentLines[3].Should().Be(" \"match_all\" {}"); + segment.ContentLines[4].Should().Be(" }"); + segment.ContentLines[5].Should().Be("}"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class MultipleConsoleApiCallsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /mydocuments/_search +{ + "from": 1, + "query": { + "match_all" {} + } +} + +POST /mydocuments/_doc +{ + "title": "New Document", + "content": "This is a sample document" +} +``` +""" +) +{ + [Fact] + public void CreatesMultipleApiSegments() + { + Block!.ApiSegments.Should().HaveCount(2); + + // First segment + var firstSegment = Block.ApiSegments[0]; + firstSegment.Header.Should().Be("GET /mydocuments/_search"); + firstSegment.ContentLines.Should().HaveCount(6); + firstSegment.ContentLines[0].Should().Be("{"); + firstSegment.ContentLines[1].Should().Be(" \"from\": 1,"); + firstSegment.ContentLines[2].Should().Be(" \"query\": {"); + firstSegment.ContentLines[3].Should().Be(" \"match_all\" {}"); + firstSegment.ContentLines[4].Should().Be(" }"); + firstSegment.ContentLines[5].Should().Be("}"); + + // Second segment + var secondSegment = Block.ApiSegments[1]; + secondSegment.Header.Should().Be("POST /mydocuments/_doc"); + secondSegment.ContentLines.Should().HaveCount(4); + secondSegment.ContentLines[0].Should().Be("{"); + secondSegment.ContentLines[1].Should().Be(" \"title\": \"New Document\","); + secondSegment.ContentLines[2].Should().Be(" \"content\": \"This is a sample document\""); + secondSegment.ContentLines[3].Should().Be("}"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithDifferentHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /api/users +{ + "size": 10 +} + +PUT /api/users/123 +{ + "name": "John Doe", + "email": "john@example.com" +} + +DELETE /api/users/123 +``` +""" +) +{ + [Fact] + public void HandlesDifferentHttpVerbs() + { + Block!.ApiSegments.Should().HaveCount(3); + + Block.ApiSegments[0].Header.Should().Be("GET /api/users"); + Block.ApiSegments[1].Header.Should().Be("PUT /api/users/123"); + Block.ApiSegments[2].Header.Should().Be("DELETE /api/users/123"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithCalloutsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /mydocuments/_search +{ + "from": 1, + "query": { + "match_all" {} <1> + } +} + +POST /mydocuments/_doc +{ + "title": "New Document" <2> +} +``` + +1. This query matches all documents +2. The document title +""" +) +{ + [Fact] + public void CreatesMultipleApiSegmentsWithCallouts() + { + Block!.ApiSegments.Should().HaveCount(2); + Block.CallOuts.Should().HaveCount(2); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithEmptyLinesTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /api/test +{ + "param": "value" +} + +POST /api/test +{ + "another": "value" +} +``` +""" +) +{ + [Fact] + public void HandlesEmptyLinesBetweenApiCalls() + { + Block!.ApiSegments.Should().HaveCount(2); + Block.ApiSegments[0].Header.Should().Be("GET /api/test"); + Block.ApiSegments[1].Header.Should().Be("POST /api/test"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithOnlyHeadersTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /api/health +POST /api/status +DELETE /api/cleanup +``` +""" +) +{ + [Fact] + public void HandlesApiCallsWithoutBodies() + { + Block!.ApiSegments.Should().HaveCount(3); + Block.ApiSegments[0].Header.Should().Be("GET /api/health"); + Block.ApiSegments[1].Header.Should().Be("POST /api/status"); + Block.ApiSegments[2].Header.Should().Be("DELETE /api/cleanup"); + + Block.ApiSegments.Should().OnlyContain(s => s.ContentLines.Count == 0); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithCalloutsOnHttpVerbsTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +GET /api/users <1> +{ + "size": 10 +} + +POST /api/users <2> +{ + "name": "John Doe" +} +``` + +1. Get all users +2. Create a new user +""" +) +{ + [Fact] + public void CreatesMultipleApiSegmentsWithCalloutsOnHttpVerbs() + { + Block!.ApiSegments.Should().HaveCount(2); + Block.CallOuts.Should().HaveCount(2); + } + + [Fact] + public void RendersCalloutsInHttpVerbHeaders() + { + Block!.ApiSegments.Should().HaveCount(2); + Block.ApiSegments[0].Header.Should().Be("GET /api/users"); + Block.ApiSegments[1].Header.Should().Be("POST /api/users"); + Block.CallOuts.Should().HaveCount(2); + } + + [Fact] + public void RendersCalloutHtmlInConsoleCodeBlocks() + { + var viewModel = new CodeViewModel + { + ApiSegments = Block!.ApiSegments, + Language = Block.Language, + Caption = null, + CrossReferenceName = null, + RawIncludedFileContents = null, + EnhancedCodeBlock = Block + }; + + var calloutHtml = viewModel.RenderLineWithCallouts(Block.ApiSegments[0].Header, Block.ApiSegments[0].LineNumber); + calloutHtml.Value.Should().Contain("code-callout"); + calloutHtml.Value.Should().Contain("data-index=\"1\""); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ConsoleWithCalloutsInJsonContentTests(ITestOutputHelper output) : ConsoleCodeBlockTests(output, +""" +```console +PUT my-index-000001 +{ + "mappings": { + "enabled": false <1> + } +} + +PUT my-index-000001/_doc/session_1 +{ + "user_id": "kimchy", + "session_data": { + "arbitrary_object": { + "some_array": [ "foo", "bar", { "baz": 2 } ] + } + }, + "last_updated": "2015-12-06T18:20:22" +} + +GET my-index-000001/_doc/session_1 <2> + +GET my-index-000001/_mapping <3> +``` + +1. The entire mapping is disabled. +2. The document can be retrieved. +3. Checking the mapping reveals that no fields have been added. +""" +) +{ + [Fact] + public void CreatesMultipleApiSegmentsWithCalloutsInJsonContent() + { + Block!.ApiSegments.Should().HaveCount(4); + Block.CallOuts.Should().HaveCount(3); + } + + [Fact] + public void RendersCalloutsInJsonContent() + { + // Test that callouts in JSON content are properly rendered + var viewModel = new CodeViewModel + { + ApiSegments = Block!.ApiSegments, + Language = Block.Language, + Caption = null, + CrossReferenceName = null, + RawIncludedFileContents = null, + EnhancedCodeBlock = Block + }; + + // The first segment should have callouts in its JSON content + var firstSegment = Block.ApiSegments[0]; + var contentHtml = viewModel.RenderContentLinesWithCallouts(firstSegment.ContentLinesWithNumbers); + contentHtml.Value.Should().Contain("code-callout"); + contentHtml.Value.Should().Contain("data-index=\"1\""); + contentHtml.Value.Should().NotContain("<1>"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().BeEmpty(); +} +