diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index 92a747e2e..cafabb9b6 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -44,12 +44,25 @@ private static void RenderCodeBlockLines(HtmlRenderer renderer, EnhancedCodeBloc } } - private static void RenderCodeBlockLine(HtmlRenderer renderer, EnhancedCodeBlock block, StringSlice slice, int i) + private static void RenderCodeBlockLine(HtmlRenderer renderer, EnhancedCodeBlock block, StringSlice slice, int lineNumber) { renderer.WriteEscape(slice); + RenderCallouts(renderer, block, lineNumber); renderer.WriteLine(); } + private static void RenderCallouts(HtmlRenderer renderer, EnhancedCodeBlock block, int lineNumber) + { + var callOuts = FindCallouts(block.CallOuts ?? [], lineNumber + 1); + foreach (var callOut in callOuts) + renderer.Write($"{callOut.Index}"); + } + + private static IEnumerable FindCallouts( + IEnumerable callOuts, + int lineNumber + ) => callOuts.Where(callOut => callOut.Line == lineNumber); + private static int GetCommonIndent(EnhancedCodeBlock block) { var commonIndent = int.MaxValue; diff --git a/src/Elastic.Markdown/Slices/Layout/_Scripts.cshtml b/src/Elastic.Markdown/Slices/Layout/_Scripts.cshtml index 1371c4565..9cfec421e 100644 --- a/src/Elastic.Markdown/Slices/Layout/_Scripts.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_Scripts.cshtml @@ -16,5 +16,5 @@ - + diff --git a/src/Elastic.Markdown/_static/custom.css b/src/Elastic.Markdown/_static/custom.css index f0b4e3978..80c66b610 100644 --- a/src/Elastic.Markdown/_static/custom.css +++ b/src/Elastic.Markdown/_static/custom.css @@ -134,4 +134,59 @@ See https://github.com/elastic/docs-builder/issues/219 for further details --color-1: var(--gray-2); --color-2: var(--gray-a4); --color-3: var(--gray-10); +} + + +/* Code Callouts */ + +.yue code span.code-callout { + display: inline-flex; + font-size: 0.75em; + border-radius: 99999px; + background-color: var(--accent-11); + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + margin: 0; + transform: translateY(-2px); +} + +.yue code span.code-callout > span { + color: white; +} + +.yue ol.code-callouts { + margin-top: 0; + counter-reset: code-callout-counter; +} + +.yue ol.code-callouts li::before { + content: counter(code-callout-counter); + position: absolute; + --size: 20px; + left: calc(-1 * var(--size) - 5px); + top: 5px; + color: white; + display: inline-flex; + font-size: 0.75em; + border-radius: 99999px; + background-color: var(--accent-11); + width: var(--size); + height: var(--size); + align-items: center; + justify-content: center; + margin: 0 0.25em; + transform: translateY(-2px); + font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace; +} + +.yue ol.code-callouts li { + margin: 0 0 0.5rem 0; + counter-increment: code-callout-counter; + position: relative; +} + +.yue ol.code-callouts li::marker { + display: none; } \ No newline at end of file diff --git a/src/Elastic.Markdown/_static/hljs.js b/src/Elastic.Markdown/_static/hljs.js new file mode 100644 index 000000000..1f757b3d9 --- /dev/null +++ b/src/Elastic.Markdown/_static/hljs.js @@ -0,0 +1,186 @@ +(function () { + // The merge HTMLPlugin was copied from https://github.com/highlightjs/highlight.js/issues/2889 + var mergeHTMLPlugin = (function () { + 'use strict'; + + var originalStream; + + /** + * @param {string} value + * @returns {string} + */ + function escapeHTML(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /* plugin itself */ + + /** @type {HLJSPlugin} */ + const mergeHTMLPlugin = { + // preserve the original HTML token stream + "before:highlightElement": ({ el }) => { + originalStream = nodeStream(el); + }, + // merge it afterwards with the highlighted token stream + "after:highlightElement": ({ el, result, text }) => { + if (!originalStream.length) return; + + const resultNode = document.createElement('div'); + resultNode.innerHTML = result.value; + result.value = mergeStreams(originalStream, nodeStream(resultNode), text); + el.innerHTML = result.value; + } + }; + + /* Stream merging support functions */ + + /** + * @typedef Event + * @property {'start'|'stop'} event + * @property {number} offset + * @property {Node} node + */ + + /** + * @param {Node} node + */ + function tag(node) { + return node.nodeName.toLowerCase(); + } + + /** + * @param {Node} node + */ + function nodeStream(node) { + /** @type Event[] */ + const result = []; + (function _nodeStream(node, offset) { + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 3) { + offset += child.nodeValue.length; + } else if (child.nodeType === 1) { + result.push({ + event: 'start', + offset: offset, + node: child + }); + offset = _nodeStream(child, offset); + // Prevent void elements from having an end tag that would actually + // double them in the output. There are more void elements in HTML + // but we list only those realistically expected in code display. + if (!tag(child).match(/br|hr|img|input/)) { + result.push({ + event: 'stop', + offset: offset, + node: child + }); + } + } + } + return offset; + })(node, 0); + return result; + } + + /** + * @param {any} original - the original stream + * @param {any} highlighted - stream of the highlighted source + * @param {string} value - the original source itself + */ + function mergeStreams(original, highlighted, value) { + let processed = 0; + let result = ''; + const nodeStack = []; + + function selectStream() { + if (!original.length || !highlighted.length) { + return original.length ? original : highlighted; + } + if (original[0].offset !== highlighted[0].offset) { + return (original[0].offset < highlighted[0].offset) ? original : highlighted; + } + + /* + To avoid starting the stream just before it should stop the order is + ensured that original always starts first and closes last: + + if (event1 == 'start' && event2 == 'start') + return original; + if (event1 == 'start' && event2 == 'stop') + return highlighted; + if (event1 == 'stop' && event2 == 'start') + return original; + if (event1 == 'stop' && event2 == 'stop') + return highlighted; + + ... which is collapsed to: + */ + return highlighted[0].event === 'start' ? original : highlighted; + } + + /** + * @param {Node} node + */ + function open(node) { + /** @param {Attr} attr */ + function attributeString(attr) { + return ' ' + attr.nodeName + '="' + escapeHTML(attr.value) + '"'; + } + // @ts-ignore + result += '<' + tag(node) + [].map.call(node.attributes, attributeString).join('') + '>'; + } + + /** + * @param {Node} node + */ + function close(node) { + result += ''; + } + + /** + * @param {Event} event + */ + function render(event) { + (event.event === 'start' ? open : close)(event.node); + } + + while (original.length || highlighted.length) { + let stream = selectStream(); + result += escapeHTML(value.substring(processed, stream[0].offset)); + processed = stream[0].offset; + if (stream === original) { + /* + On any opening or closing tag of the original markup we first close + the entire highlighted node stack, then render the original tag along + with all the following original tags at the same offset and then + reopen all the tags on the highlighted stack. + */ + nodeStack.reverse().forEach(close); + do { + render(stream.splice(0, 1)[0]); + stream = selectStream(); + } while (stream === original && stream.length && stream[0].offset === processed); + nodeStack.reverse().forEach(open); + } else { + if (stream[0].event === 'start') { + nodeStack.push(stream[0].node); + } else { + nodeStack.pop(); + } + render(stream.splice(0, 1)[0]); + } + } + return result + escapeHTML(value.substr(processed)); + } + + return mergeHTMLPlugin; + }()); + + hljs.addPlugin(mergeHTMLPlugin); + hljs.highlightAll(); +})(); \ No newline at end of file