Skip to content

Commit a1deace

Browse files
authored
Allow multiple classic callouts in one line (#355)
* Allow multiple class callouts in one line * format * Cleanup * Fix case where code itself contains `<` or `>` * Add another test
1 parent bb4db4b commit a1deace

File tree

6 files changed

+203
-59
lines changed

6 files changed

+203
-59
lines changed

src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class EnhancedCodeBlock(BlockParser parser, ParserContext context)
2020

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

23-
public List<CallOut>? CallOuts { get; set; }
23+
public List<CallOut> CallOuts { get; set; } = [];
2424

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

src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ private static void RenderCallouts(HtmlRenderer renderer, EnhancedCodeBlock bloc
5555
{
5656
var callOuts = FindCallouts(block.CallOuts ?? [], lineNumber + 1);
5757
foreach (var callOut in callOuts)
58-
renderer.Write($"<span class=\"code-callout\">{callOut.Index}</span>");
58+
renderer.Write($"<span class=\"code-callout\" data-index=\"{callOut.Index}\">{callOut.Index}</span>");
5959
}
6060

6161
private static IEnumerable<CallOut> FindCallouts(

src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -98,39 +98,42 @@ public override bool Close(BlockProcessor processor, Block block)
9898
if (codeBlock.OpeningFencedCharCount > 3)
9999
continue;
100100

101-
if (span.IndexOf("<") < 0 && span.IndexOf("//") < 0)
102-
continue;
103-
104-
CallOut? callOut = null;
105-
106-
if (span.IndexOf("<") > 0)
101+
List<CallOut> callOuts = [];
102+
var hasClassicCallout = span.IndexOf("<") > 0;
103+
if (hasClassicCallout)
107104
{
108105
var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span);
109-
callOut = EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false);
106+
callOuts.AddRange(
107+
EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false)
108+
);
110109
}
111-
112110
// only support magic callouts for smaller line lengths
113-
if (callOut is null && span.Length < 200)
111+
if (callOuts.Count == 0 && span.Length < 200)
114112
{
115113
var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span);
116-
callOut = EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine,
117-
true);
114+
callOuts.AddRange(
115+
EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true)
116+
);
118117
}
119-
120-
if (callOut is null)
121-
continue;
122-
123-
codeBlock.CallOuts ??= [];
124-
codeBlock.CallOuts.Add(callOut);
118+
codeBlock.CallOuts.AddRange(callOuts);
125119
}
126120

127121
//update string slices to ignore call outs
128-
if (codeBlock.CallOuts is not null)
122+
if (codeBlock.CallOuts.Count > 0)
129123
{
130-
foreach (var callout in codeBlock.CallOuts)
124+
125+
var callouts = codeBlock.CallOuts.Aggregate(new Dictionary<int, CallOut>(), (acc, curr) =>
126+
{
127+
if (acc.TryAdd(curr.Line, curr))
128+
return acc;
129+
if (acc[curr.Line].SliceStart > curr.SliceStart)
130+
acc[curr.Line] = curr;
131+
return acc;
132+
});
133+
134+
foreach (var callout in callouts.Values)
131135
{
132136
var line = lines.Lines[callout.Line - 1];
133-
134137
var newSpan = line.Slice.AsSpan()[..callout.SliceStart];
135138
var s = new StringSlice(newSpan.ToString());
136139
lines.Lines[callout.Line - 1] = new StringLine(ref s);
@@ -149,44 +152,83 @@ public override bool Close(BlockProcessor processor, Block block)
149152
return base.Close(processor, block);
150153
}
151154

152-
private static CallOut? EnumerateAnnotations(Regex.ValueMatchEnumerator matches,
155+
private static List<CallOut> EnumerateAnnotations(Regex.ValueMatchEnumerator matches,
153156
ref ReadOnlySpan<char> span,
154157
ref int callOutIndex,
155158
int originatingLine,
156159
bool inlineCodeAnnotation)
157160
{
161+
var callOuts = new List<CallOut>();
158162
foreach (var match in matches)
159163
{
160164
if (match.Length == 0)
161165
continue;
162166

163-
var startIndex = span.LastIndexOf("<");
164-
if (!inlineCodeAnnotation && startIndex <= 0)
165-
continue;
166167
if (inlineCodeAnnotation)
167168
{
168-
startIndex = Math.Max(span.LastIndexOf("//"), span.LastIndexOf('#'));
169-
if (startIndex <= 0)
170-
continue;
169+
var callOut = ParseMagicCallout(match, ref span, ref callOutIndex, originatingLine);
170+
if (callOut != null)
171+
return [callOut];
172+
continue;
171173
}
172174

175+
var classicCallOuts = ParseClassicCallOuts(match, ref span, ref callOutIndex, originatingLine);
176+
callOuts.AddRange(classicCallOuts);
177+
}
178+
179+
return callOuts;
180+
}
181+
182+
private static CallOut? ParseMagicCallout(ValueMatch match, ref ReadOnlySpan<char> span, ref int callOutIndex, int originatingLine)
183+
{
184+
var startIndex = Math.Max(span.LastIndexOf("//"), span.LastIndexOf('#'));
185+
if (startIndex <= 0)
186+
return null;
187+
188+
callOutIndex++;
189+
var callout = span.Slice(match.Index + startIndex, match.Length - startIndex);
190+
191+
return new CallOut
192+
{
193+
Index = callOutIndex,
194+
Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(),
195+
InlineCodeAnnotation = true,
196+
SliceStart = startIndex,
197+
Line = originatingLine,
198+
};
199+
}
200+
201+
private static List<CallOut> ParseClassicCallOuts(ValueMatch match, ref ReadOnlySpan<char> span, ref int callOutIndex, int originatingLine)
202+
{
203+
var indexOfLastComment = Math.Max(span.LastIndexOf('#'), span.LastIndexOf("//"));
204+
var startIndex = span.LastIndexOf('<');
205+
if (startIndex <= 0)
206+
return [];
207+
208+
var allStartIndices = new List<int>();
209+
for (var i = 0; i < span.Length; i++)
210+
{
211+
if (span[i] == '<')
212+
allStartIndices.Add(i);
213+
}
214+
var callOuts = new List<CallOut>();
215+
foreach (var individualStartIndex in allStartIndices)
216+
{
173217
callOutIndex++;
174-
var callout = span.Slice(match.Index + startIndex, match.Length - startIndex);
175-
var index = callOutIndex;
176-
if (!inlineCodeAnnotation && int.TryParse(callout.Trim(['<', '>']), out index))
218+
var endIndex = span.Slice(match.Index + individualStartIndex).IndexOf('>') + 1;
219+
var callout = span.Slice(match.Index + individualStartIndex, endIndex);
220+
if (int.TryParse(callout.Trim(['<', '>']), out var index))
177221
{
178-
222+
callOuts.Add(new CallOut
223+
{
224+
Index = index,
225+
Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(),
226+
InlineCodeAnnotation = false,
227+
SliceStart = indexOfLastComment > 0 ? indexOfLastComment : startIndex,
228+
Line = originatingLine,
229+
});
179230
}
180-
return new CallOut
181-
{
182-
Index = index,
183-
Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(),
184-
InlineCodeAnnotation = inlineCodeAnnotation,
185-
SliceStart = startIndex,
186-
Line = originatingLine,
187-
};
188231
}
189-
190-
return null;
232+
return callOuts;
191233
}
192234
}

src/Elastic.Markdown/_static/copybutton.js

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,14 @@ function escapeRegExp(string) {
153153
* Removes excluded text from a Node.
154154
*
155155
* @param {Node} target Node to filter.
156-
* @param {string} exclude CSS selector of nodes to exclude.
156+
* @param {string[]} excludes CSS selector of nodes to exclude.
157157
* @returns {DOMString} Text from `target` with text removed.
158158
*/
159-
function filterText(target, exclude) {
159+
function filterText(target, excludes) {
160160
const clone = target.cloneNode(true); // clone as to not modify the live DOM
161-
if (exclude) {
162-
// remove excluded nodes
163-
clone.querySelectorAll(exclude).forEach(node => node.remove());
164-
}
161+
excludes.forEach(exclude => {
162+
clone.querySelectorAll(excludes).forEach(node => node.remove());
163+
})
165164
return clone.innerText;
166165
}
167166

@@ -222,11 +221,9 @@ function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onl
222221

223222
var copyTargetText = (trigger) => {
224223
var target = document.querySelector(trigger.attributes['data-clipboard-target'].value);
225-
226224
// get filtered text
227-
let exclude = '.linenos';
228-
229-
let text = filterText(target, exclude);
225+
let excludes = ['.code-callout', '.linenos'];
226+
let text = filterText(target, excludes);
230227
return formatCopyText(text, '', false, true, true, true, '', '')
231228
}
232229

src/Elastic.Markdown/_static/custom.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ See https://github.com/elastic/docs-builder/issues/219 for further details
150150
justify-content: center;
151151
margin: 0;
152152
transform: translateY(-2px);
153+
user-select: none; /* Standard */
154+
-webkit-user-select: none; /* Safari */
155+
-moz-user-select: none; /* Firefox */
156+
-ms-user-select: none; /* IE10+/Edge */
157+
user-select: none; /* Standard */
158+
}
159+
160+
.yue code span.code-callout:not(:last-child) {
161+
margin-right: 5px;
153162
}
154163

155164
.yue code span.code-callout > span {

tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,21 +148,117 @@ public void ParsesAllForLineInformation() => Block!.CallOuts
148148

149149
public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp",
150150
"""
151-
var x = 1; <1>
152-
var y = x - 2;
153-
var z = y - 2; <2>
151+
receivers: <1>
152+
# ...
153+
otlp:
154+
protocols:
155+
grpc:
156+
endpoint: 0.0.0.0:4317
157+
http:
158+
endpoint: 0.0.0.0:4318
159+
processors: <2>
160+
# ...
161+
memory_limiter:
162+
check_interval: 1s
163+
limit_mib: 2000
164+
batch:
165+
166+
exporters:
167+
debug:
168+
verbosity: detailed <3>
169+
otlp: <4>
170+
# Elastic APM server https endpoint without the "https://" prefix
171+
endpoint: "${env:ELASTIC_APM_SERVER_ENDPOINT}" <5> <7>
172+
headers:
173+
# Elastic APM Server secret token
174+
Authorization: "Bearer ${env:ELASTIC_APM_SECRET_TOKEN}" <6> <7>
175+
176+
service:
177+
pipelines:
178+
traces:
179+
receivers: [otlp]
180+
processors: [..., memory_limiter, batch]
181+
exporters: [debug, otlp]
182+
metrics:
183+
receivers: [otlp]
184+
processors: [..., memory_limiter, batch]
185+
exporters: [debug, otlp]
186+
logs: <8>
187+
receivers: [otlp]
188+
processors: [..., memory_limiter, batch]
189+
exporters: [debug, otlp]
154190
""",
155191
"""
156-
1. First callout
157-
2. Second callout
192+
1. The receivers, like the OTLP receiver, that forward data emitted by APM agents, or the host metrics receiver.
193+
2. We recommend using the Batch processor and the memory limiter processor. For more information, see recommended processors.
194+
3. The debug exporter is helpful for troubleshooting, and supports configurable verbosity levels: basic (default), normal, and detailed.
195+
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.
196+
5. Hostname and port of the APM Server endpoint. For example, elastic-apm-server:8200.
197+
6. Credential for Elastic APM secret token authorization (Authorization: "Bearer a_secret_token") or API key authorization (Authorization: "ApiKey an_api_key").
198+
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).
199+
8. [preview] To send OpenTelemetry logs to {stack} version 8.0+, declare a logs pipeline.
158200
"""
159201

160202
)
203+
{
204+
[Fact]
205+
public void ParsesClassicCallouts()
206+
{
207+
Block!.CallOuts
208+
.Should().NotBeNullOrEmpty()
209+
.And.HaveCount(9)
210+
.And.OnlyContain(c => c.Text.StartsWith("<"));
211+
212+
Block!.UniqueCallOuts
213+
.Should().NotBeNullOrEmpty()
214+
.And.HaveCount(8);
215+
}
216+
217+
[Fact]
218+
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
219+
}
220+
221+
public class MultipleCalloutsInOneLine(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp",
222+
"""
223+
var x = 1; // <1>
224+
var y = x - 2;
225+
var z = y - 2; // <1> <2>
226+
""",
227+
"""
228+
1. First callout
229+
2. Second callout
230+
"""
231+
)
161232
{
162233
[Fact]
163234
public void ParsesMagicCallOuts() => Block!.CallOuts
164235
.Should().NotBeNullOrEmpty()
165-
.And.HaveCount(2)
236+
.And.HaveCount(3)
237+
.And.OnlyContain(c => c.Text.StartsWith("<"));
238+
239+
[Fact]
240+
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
241+
}
242+
243+
public class CodeBlockWithChevronInsideCode(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp",
244+
"""
245+
app.UseFilter<StopwatchFilter>(); <1>
246+
app.UseFilter<CatchExceptionFilter>(); <2>
247+
248+
var x = 1; <1>
249+
var y = x - 2;
250+
var z = y - 2; <1> <2>
251+
""",
252+
"""
253+
1. First callout
254+
2. Second callout
255+
"""
256+
)
257+
{
258+
[Fact]
259+
public void ParsesMagicCallOuts() => Block!.CallOuts
260+
.Should().NotBeNullOrEmpty()
261+
.And.HaveCount(5)
166262
.And.OnlyContain(c => c.Text.StartsWith("<"));
167263

168264
[Fact]

0 commit comments

Comments
 (0)