diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs index ae148f92f..c4bf88502 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -20,7 +20,7 @@ public class EnhancedCodeBlock(BlockParser parser, ParserContext context) public int OpeningLength => Info?.Length ?? 0 + 3; - public List? CallOuts { get; set; } + public List CallOuts { get; set; } = []; public IReadOnlyCollection UniqueCallOuts => CallOuts?.DistinctBy(c => c.Index).ToList() ?? []; diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index cafabb9b6..348acb6fe 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -55,7 +55,7 @@ private static void RenderCallouts(HtmlRenderer renderer, EnhancedCodeBlock bloc { var callOuts = FindCallouts(block.CallOuts ?? [], lineNumber + 1); foreach (var callOut in callOuts) - renderer.Write($"{callOut.Index}"); + renderer.Write($"{callOut.Index}"); } private static IEnumerable FindCallouts( diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs index d836d8763..26b360543 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs @@ -98,39 +98,42 @@ public override bool Close(BlockProcessor processor, Block block) if (codeBlock.OpeningFencedCharCount > 3) continue; - if (span.IndexOf("<") < 0 && span.IndexOf("//") < 0) - continue; - - CallOut? callOut = null; - - if (span.IndexOf("<") > 0) + List callOuts = []; + var hasClassicCallout = span.IndexOf("<") > 0; + if (hasClassicCallout) { var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span); - callOut = EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false); + callOuts.AddRange( + EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false) + ); } - // only support magic callouts for smaller line lengths - if (callOut is null && span.Length < 200) + if (callOuts.Count == 0 && span.Length < 200) { var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span); - callOut = EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, - true); + callOuts.AddRange( + EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true) + ); } - - if (callOut is null) - continue; - - codeBlock.CallOuts ??= []; - codeBlock.CallOuts.Add(callOut); + codeBlock.CallOuts.AddRange(callOuts); } //update string slices to ignore call outs - if (codeBlock.CallOuts is not null) + if (codeBlock.CallOuts.Count > 0) { - foreach (var callout in codeBlock.CallOuts) + + 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); @@ -149,44 +152,83 @@ public override bool Close(BlockProcessor processor, Block block) return base.Close(processor, block); } - private static CallOut? EnumerateAnnotations(Regex.ValueMatchEnumerator matches, + private static List EnumerateAnnotations(Regex.ValueMatchEnumerator matches, ref ReadOnlySpan span, ref int callOutIndex, int originatingLine, bool inlineCodeAnnotation) { + var callOuts = new List(); foreach (var match in matches) { if (match.Length == 0) continue; - var startIndex = span.LastIndexOf("<"); - if (!inlineCodeAnnotation && startIndex <= 0) - continue; if (inlineCodeAnnotation) { - startIndex = Math.Max(span.LastIndexOf("//"), span.LastIndexOf('#')); - if (startIndex <= 0) - continue; + var callOut = ParseMagicCallout(match, ref span, ref callOutIndex, originatingLine); + if (callOut != null) + return [callOut]; + continue; } + var classicCallOuts = ParseClassicCallOuts(match, ref span, ref callOutIndex, originatingLine); + callOuts.AddRange(classicCallOuts); + } + + return callOuts; + } + + private static CallOut? ParseMagicCallout(ValueMatch match, ref ReadOnlySpan span, ref int callOutIndex, int originatingLine) + { + var startIndex = Math.Max(span.LastIndexOf("//"), span.LastIndexOf('#')); + if (startIndex <= 0) + return null; + + callOutIndex++; + var callout = span.Slice(match.Index + startIndex, match.Length - startIndex); + + return new CallOut + { + Index = callOutIndex, + Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(), + InlineCodeAnnotation = true, + SliceStart = startIndex, + Line = originatingLine, + }; + } + + private static List ParseClassicCallOuts(ValueMatch match, ref ReadOnlySpan span, ref int callOutIndex, int originatingLine) + { + var indexOfLastComment = Math.Max(span.LastIndexOf('#'), span.LastIndexOf("//")); + var startIndex = span.LastIndexOf('<'); + if (startIndex <= 0) + return []; + + var allStartIndices = new List(); + for (var i = 0; i < span.Length; i++) + { + if (span[i] == '<') + allStartIndices.Add(i); + } + var callOuts = new List(); + foreach (var individualStartIndex in allStartIndices) + { callOutIndex++; - var callout = span.Slice(match.Index + startIndex, match.Length - startIndex); - var index = callOutIndex; - if (!inlineCodeAnnotation && int.TryParse(callout.Trim(['<', '>']), out index)) + var endIndex = span.Slice(match.Index + individualStartIndex).IndexOf('>') + 1; + var callout = span.Slice(match.Index + individualStartIndex, endIndex); + if (int.TryParse(callout.Trim(['<', '>']), out var index)) { - + callOuts.Add(new CallOut + { + Index = index, + Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(), + InlineCodeAnnotation = false, + SliceStart = indexOfLastComment > 0 ? indexOfLastComment : startIndex, + Line = originatingLine, + }); } - return new CallOut - { - Index = index, - Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(), - InlineCodeAnnotation = inlineCodeAnnotation, - SliceStart = startIndex, - Line = originatingLine, - }; } - - return null; + return callOuts; } } diff --git a/src/Elastic.Markdown/_static/copybutton.js b/src/Elastic.Markdown/_static/copybutton.js index 2ea7ff3e2..7c8a5eb6c 100644 --- a/src/Elastic.Markdown/_static/copybutton.js +++ b/src/Elastic.Markdown/_static/copybutton.js @@ -153,15 +153,14 @@ function escapeRegExp(string) { * Removes excluded text from a Node. * * @param {Node} target Node to filter. - * @param {string} exclude CSS selector of nodes to exclude. + * @param {string[]} excludes CSS selector of nodes to exclude. * @returns {DOMString} Text from `target` with text removed. */ -function filterText(target, exclude) { +function filterText(target, excludes) { const clone = target.cloneNode(true); // clone as to not modify the live DOM - if (exclude) { - // remove excluded nodes - clone.querySelectorAll(exclude).forEach(node => node.remove()); - } + excludes.forEach(exclude => { + clone.querySelectorAll(excludes).forEach(node => node.remove()); + }) return clone.innerText; } @@ -222,11 +221,9 @@ function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onl var copyTargetText = (trigger) => { var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); - // get filtered text - let exclude = '.linenos'; - - let text = filterText(target, exclude); + let excludes = ['.code-callout', '.linenos']; + let text = filterText(target, excludes); return formatCopyText(text, '', false, true, true, true, '', '') } diff --git a/src/Elastic.Markdown/_static/custom.css b/src/Elastic.Markdown/_static/custom.css index 80c66b610..d5b3b9656 100644 --- a/src/Elastic.Markdown/_static/custom.css +++ b/src/Elastic.Markdown/_static/custom.css @@ -150,6 +150,15 @@ See https://github.com/elastic/docs-builder/issues/219 for further details justify-content: center; margin: 0; transform: translateY(-2px); + user-select: none; /* Standard */ + -webkit-user-select: none; /* Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+/Edge */ + user-select: none; /* Standard */ +} + +.yue code span.code-callout:not(:last-child) { + margin-right: 5px; } .yue code span.code-callout > span { diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs index 8edde18d0..b63c12e6f 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -148,21 +148,117 @@ public void ParsesAllForLineInformation() => Block!.CallOuts public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", """ -var x = 1; <1> -var y = x - 2; -var z = y - 2; <2> +receivers: <1> + # ... + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 +processors: <2> + # ... + memory_limiter: + check_interval: 1s + limit_mib: 2000 + batch: + +exporters: + debug: + verbosity: detailed <3> + otlp: <4> + # Elastic APM server https endpoint without the "https://" prefix + endpoint: "${env:ELASTIC_APM_SERVER_ENDPOINT}" <5> <7> + headers: + # Elastic APM Server secret token + Authorization: "Bearer ${env:ELASTIC_APM_SECRET_TOKEN}" <6> <7> + +service: + pipelines: + traces: + receivers: [otlp] + processors: [..., memory_limiter, batch] + exporters: [debug, otlp] + metrics: + receivers: [otlp] + processors: [..., memory_limiter, batch] + exporters: [debug, otlp] + logs: <8> + receivers: [otlp] + processors: [..., memory_limiter, batch] + exporters: [debug, otlp] """, """ -1. First callout -2. Second callout +1. The receivers, like the OTLP receiver, that forward data emitted by APM agents, or the host metrics receiver. +2. We recommend using the Batch processor and the memory limiter processor. For more information, see recommended processors. +3. The debug exporter is helpful for troubleshooting, and supports configurable verbosity levels: basic (default), normal, and detailed. +4. Elastic {observability} endpoint configuration. APM Server supports a ProtoBuf payload via both the OTLP protocol over gRPC transport (OTLP/gRPC) and the OTLP protocol over HTTP transport (OTLP/HTTP). To learn more about these exporters, see the OpenTelemetry Collector documentation: OTLP/HTTP Exporter or OTLP/gRPC exporter. When adding an endpoint to an existing configuration an optional name component can be added, like otlp/elastic, to distinguish endpoints as described in the OpenTelemetry Collector Configuration Basics. +5. Hostname and port of the APM Server endpoint. For example, elastic-apm-server:8200. +6. Credential for Elastic APM secret token authorization (Authorization: "Bearer a_secret_token") or API key authorization (Authorization: "ApiKey an_api_key"). +7. Environment-specific configuration parameters can be conveniently passed in as environment variables documented here (e.g. ELASTIC_APM_SERVER_ENDPOINT and ELASTIC_APM_SECRET_TOKEN). +8. [preview] To send OpenTelemetry logs to {stack} version 8.0+, declare a logs pipeline. """ ) +{ + [Fact] + public void ParsesClassicCallouts() + { + Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(9) + .And.OnlyContain(c => c.Text.StartsWith("<")); + + Block!.UniqueCallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(8); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class MultipleCalloutsInOneLine(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", + """ + var x = 1; // <1> + var y = x - 2; + var z = y - 2; // <1> <2> + """, + """ + 1. First callout + 2. Second callout + """ +) { [Fact] public void ParsesMagicCallOuts() => Block!.CallOuts .Should().NotBeNullOrEmpty() - .And.HaveCount(2) + .And.HaveCount(3) + .And.OnlyContain(c => c.Text.StartsWith("<")); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class CodeBlockWithChevronInsideCode(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", + """ + app.UseFilter(); <1> + app.UseFilter(); <2> + + var x = 1; <1> + var y = x - 2; + var z = y - 2; <1> <2> + """, + """ + 1. First callout + 2. Second callout + """ +) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(5) .And.OnlyContain(c => c.Text.StartsWith("<")); [Fact]