Skip to content

Commit

Permalink
Allow multiple classic callouts in one line (#355)
Browse files Browse the repository at this point in the history
* Allow multiple class callouts in one line

* format

* Cleanup

* Fix case where code itself contains `<` or `>`

* Add another test
  • Loading branch information
reakaleek authored Jan 28, 2025
1 parent bb4db4b commit a1deace
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 59 deletions.
2 changes: 1 addition & 1 deletion src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class EnhancedCodeBlock(BlockParser parser, ParserContext context)

public int OpeningLength => Info?.Length ?? 0 + 3;

public List<CallOut>? CallOuts { get; set; }
public List<CallOut> CallOuts { get; set; } = [];

public IReadOnlyCollection<CallOut> UniqueCallOuts => CallOuts?.DistinctBy(c => c.Index).ToList() ?? [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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($"<span class=\"code-callout\">{callOut.Index}</span>");
renderer.Write($"<span class=\"code-callout\" data-index=\"{callOut.Index}\">{callOut.Index}</span>");
}

private static IEnumerable<CallOut> FindCallouts(
Expand Down
124 changes: 83 additions & 41 deletions src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallOut> 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<int, CallOut>(), (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);
Expand All @@ -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<CallOut> EnumerateAnnotations(Regex.ValueMatchEnumerator matches,
ref ReadOnlySpan<char> span,
ref int callOutIndex,
int originatingLine,
bool inlineCodeAnnotation)
{
var callOuts = new List<CallOut>();
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<char> 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<CallOut> ParseClassicCallOuts(ValueMatch match, ref ReadOnlySpan<char> 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<int>();
for (var i = 0; i < span.Length; i++)
{
if (span[i] == '<')
allStartIndices.Add(i);
}
var callOuts = new List<CallOut>();
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;
}
}
17 changes: 7 additions & 10 deletions src/Elastic.Markdown/_static/copybutton.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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, '', '')
}

Expand Down
9 changes: 9 additions & 0 deletions src/Elastic.Markdown/_static/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
108 changes: 102 additions & 6 deletions tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StopwatchFilter>(); <1>
app.UseFilter<CatchExceptionFilter>(); <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]
Expand Down

0 comments on commit a1deace

Please sign in to comment.