Skip to content

Commit 764e19a

Browse files
authored
Render callouts in code block (#346)
* Fix indentation when code block is in a list * Format * Render callouts in code block
1 parent ea43fc2 commit 764e19a

File tree

4 files changed

+256
-2
lines changed

4 files changed

+256
-2
lines changed

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,25 @@ private static void RenderCodeBlockLines(HtmlRenderer renderer, EnhancedCodeBloc
4444
}
4545
}
4646

47-
private static void RenderCodeBlockLine(HtmlRenderer renderer, EnhancedCodeBlock block, StringSlice slice, int i)
47+
private static void RenderCodeBlockLine(HtmlRenderer renderer, EnhancedCodeBlock block, StringSlice slice, int lineNumber)
4848
{
4949
renderer.WriteEscape(slice);
50+
RenderCallouts(renderer, block, lineNumber);
5051
renderer.WriteLine();
5152
}
5253

54+
private static void RenderCallouts(HtmlRenderer renderer, EnhancedCodeBlock block, int lineNumber)
55+
{
56+
var callOuts = FindCallouts(block.CallOuts ?? [], lineNumber + 1);
57+
foreach (var callOut in callOuts)
58+
renderer.Write($"<span class=\"code-callout\">{callOut.Index}</span>");
59+
}
60+
61+
private static IEnumerable<CallOut> FindCallouts(
62+
IEnumerable<CallOut> callOuts,
63+
int lineNumber
64+
) => callOuts.Where(callOut => callOut.Line == lineNumber);
65+
5366
private static int GetCommonIndent(EnhancedCodeBlock block)
5467
{
5568
var commonIndent = int.MaxValue;

src/Elastic.Markdown/Slices/Layout/_Scripts.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@
1616

1717
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
1818
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
19-
<script>hljs.highlightAll();</script>
19+
<script src="@Model.Static("hljs.js")"></script>
2020
<script src="https://unpkg.com/[email protected]/dist/mermaid.min.js"></script>

src/Elastic.Markdown/_static/custom.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,59 @@ See https://github.com/elastic/docs-builder/issues/219 for further details
134134
--color-1: var(--gray-2);
135135
--color-2: var(--gray-a4);
136136
--color-3: var(--gray-10);
137+
}
138+
139+
140+
/* Code Callouts */
141+
142+
.yue code span.code-callout {
143+
display: inline-flex;
144+
font-size: 0.75em;
145+
border-radius: 99999px;
146+
background-color: var(--accent-11);
147+
width: 20px;
148+
height: 20px;
149+
align-items: center;
150+
justify-content: center;
151+
margin: 0;
152+
transform: translateY(-2px);
153+
}
154+
155+
.yue code span.code-callout > span {
156+
color: white;
157+
}
158+
159+
.yue ol.code-callouts {
160+
margin-top: 0;
161+
counter-reset: code-callout-counter;
162+
}
163+
164+
.yue ol.code-callouts li::before {
165+
content: counter(code-callout-counter);
166+
position: absolute;
167+
--size: 20px;
168+
left: calc(-1 * var(--size) - 5px);
169+
top: 5px;
170+
color: white;
171+
display: inline-flex;
172+
font-size: 0.75em;
173+
border-radius: 99999px;
174+
background-color: var(--accent-11);
175+
width: var(--size);
176+
height: var(--size);
177+
align-items: center;
178+
justify-content: center;
179+
margin: 0 0.25em;
180+
transform: translateY(-2px);
181+
font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;
182+
}
183+
184+
.yue ol.code-callouts li {
185+
margin: 0 0 0.5rem 0;
186+
counter-increment: code-callout-counter;
187+
position: relative;
188+
}
189+
190+
.yue ol.code-callouts li::marker {
191+
display: none;
137192
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
(function () {
2+
// The merge HTMLPlugin was copied from https://github.com/highlightjs/highlight.js/issues/2889
3+
var mergeHTMLPlugin = (function () {
4+
'use strict';
5+
6+
var originalStream;
7+
8+
/**
9+
* @param {string} value
10+
* @returns {string}
11+
*/
12+
function escapeHTML(value) {
13+
return value
14+
.replace(/&/g, '&amp;')
15+
.replace(/</g, '&lt;')
16+
.replace(/>/g, '&gt;')
17+
.replace(/"/g, '&quot;')
18+
.replace(/'/g, '&#x27;');
19+
}
20+
21+
/* plugin itself */
22+
23+
/** @type {HLJSPlugin} */
24+
const mergeHTMLPlugin = {
25+
// preserve the original HTML token stream
26+
"before:highlightElement": ({ el }) => {
27+
originalStream = nodeStream(el);
28+
},
29+
// merge it afterwards with the highlighted token stream
30+
"after:highlightElement": ({ el, result, text }) => {
31+
if (!originalStream.length) return;
32+
33+
const resultNode = document.createElement('div');
34+
resultNode.innerHTML = result.value;
35+
result.value = mergeStreams(originalStream, nodeStream(resultNode), text);
36+
el.innerHTML = result.value;
37+
}
38+
};
39+
40+
/* Stream merging support functions */
41+
42+
/**
43+
* @typedef Event
44+
* @property {'start'|'stop'} event
45+
* @property {number} offset
46+
* @property {Node} node
47+
*/
48+
49+
/**
50+
* @param {Node} node
51+
*/
52+
function tag(node) {
53+
return node.nodeName.toLowerCase();
54+
}
55+
56+
/**
57+
* @param {Node} node
58+
*/
59+
function nodeStream(node) {
60+
/** @type Event[] */
61+
const result = [];
62+
(function _nodeStream(node, offset) {
63+
for (let child = node.firstChild; child; child = child.nextSibling) {
64+
if (child.nodeType === 3) {
65+
offset += child.nodeValue.length;
66+
} else if (child.nodeType === 1) {
67+
result.push({
68+
event: 'start',
69+
offset: offset,
70+
node: child
71+
});
72+
offset = _nodeStream(child, offset);
73+
// Prevent void elements from having an end tag that would actually
74+
// double them in the output. There are more void elements in HTML
75+
// but we list only those realistically expected in code display.
76+
if (!tag(child).match(/br|hr|img|input/)) {
77+
result.push({
78+
event: 'stop',
79+
offset: offset,
80+
node: child
81+
});
82+
}
83+
}
84+
}
85+
return offset;
86+
})(node, 0);
87+
return result;
88+
}
89+
90+
/**
91+
* @param {any} original - the original stream
92+
* @param {any} highlighted - stream of the highlighted source
93+
* @param {string} value - the original source itself
94+
*/
95+
function mergeStreams(original, highlighted, value) {
96+
let processed = 0;
97+
let result = '';
98+
const nodeStack = [];
99+
100+
function selectStream() {
101+
if (!original.length || !highlighted.length) {
102+
return original.length ? original : highlighted;
103+
}
104+
if (original[0].offset !== highlighted[0].offset) {
105+
return (original[0].offset < highlighted[0].offset) ? original : highlighted;
106+
}
107+
108+
/*
109+
To avoid starting the stream just before it should stop the order is
110+
ensured that original always starts first and closes last:
111+
112+
if (event1 == 'start' && event2 == 'start')
113+
return original;
114+
if (event1 == 'start' && event2 == 'stop')
115+
return highlighted;
116+
if (event1 == 'stop' && event2 == 'start')
117+
return original;
118+
if (event1 == 'stop' && event2 == 'stop')
119+
return highlighted;
120+
121+
... which is collapsed to:
122+
*/
123+
return highlighted[0].event === 'start' ? original : highlighted;
124+
}
125+
126+
/**
127+
* @param {Node} node
128+
*/
129+
function open(node) {
130+
/** @param {Attr} attr */
131+
function attributeString(attr) {
132+
return ' ' + attr.nodeName + '="' + escapeHTML(attr.value) + '"';
133+
}
134+
// @ts-ignore
135+
result += '<' + tag(node) + [].map.call(node.attributes, attributeString).join('') + '>';
136+
}
137+
138+
/**
139+
* @param {Node} node
140+
*/
141+
function close(node) {
142+
result += '</' + tag(node) + '>';
143+
}
144+
145+
/**
146+
* @param {Event} event
147+
*/
148+
function render(event) {
149+
(event.event === 'start' ? open : close)(event.node);
150+
}
151+
152+
while (original.length || highlighted.length) {
153+
let stream = selectStream();
154+
result += escapeHTML(value.substring(processed, stream[0].offset));
155+
processed = stream[0].offset;
156+
if (stream === original) {
157+
/*
158+
On any opening or closing tag of the original markup we first close
159+
the entire highlighted node stack, then render the original tag along
160+
with all the following original tags at the same offset and then
161+
reopen all the tags on the highlighted stack.
162+
*/
163+
nodeStack.reverse().forEach(close);
164+
do {
165+
render(stream.splice(0, 1)[0]);
166+
stream = selectStream();
167+
} while (stream === original && stream.length && stream[0].offset === processed);
168+
nodeStack.reverse().forEach(open);
169+
} else {
170+
if (stream[0].event === 'start') {
171+
nodeStack.push(stream[0].node);
172+
} else {
173+
nodeStack.pop();
174+
}
175+
render(stream.splice(0, 1)[0]);
176+
}
177+
}
178+
return result + escapeHTML(value.substr(processed));
179+
}
180+
181+
return mergeHTMLPlugin;
182+
}());
183+
184+
hljs.addPlugin(mergeHTMLPlugin);
185+
hljs.highlightAll();
186+
})();

0 commit comments

Comments
 (0)