From 94854b75ae8b079abeab101dde9f987c696f93bb Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 27 Jan 2025 09:27:49 +0100 Subject: [PATCH 1/3] Fix indentation when code block is in a list --- docs/testing/index.md | 27 ++++++++- .../EnhancedCodeBlockHtmlRenderer.cs | 59 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/testing/index.md b/docs/testing/index.md index 9f87ba1fb..3265aea28 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -5,4 +5,29 @@ The files in this directory are used for testing purposes. Do not edit these fil ###### [#synthetics-config-file] -% [Non Existing Link](./non-existing.md) \ No newline at end of file +% [Non Existing Link](./non-existing.md) + +```json +{ + "key": "value" +} +``` + + ```json + { + "key": "value" + } + ``` + +1. this is a list + ```json + { + "key": "value" + } + ``` + 1. this is a sub-list + ```json + { + "key": "value" + } + ``` \ No newline at end of file diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index b5b04c14c..b7f55ceba 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -5,6 +5,7 @@ using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Slices.Directives; +using Markdig.Helpers; using Markdig.Renderers; using Markdig.Renderers.Html; using Markdig.Syntax; @@ -14,15 +15,71 @@ namespace Elastic.Markdown.Myst.CodeBlocks; public class EnhancedCodeBlockHtmlRenderer : HtmlObjectRenderer { + private const int TabWidth = 4; private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer renderer, EnhancedCodeBlock block) { var html = slice.RenderAsync().GetAwaiter().GetResult(); var blocks = html.Split("[CONTENT]", 2, StringSplitOptions.RemoveEmptyEntries); renderer.Write(blocks[0]); - renderer.WriteLeafRawLines(block, true, true, false); + RenderCodeBlockLines(renderer, block); renderer.Write(blocks[1]); } + + /// + /// Renders the code block lines while also removing the common indentation level. + /// Required because EnableTrackTrivia preserves extra indentation. + /// + private static void RenderCodeBlockLines(HtmlRenderer renderer, EnhancedCodeBlock block) + { + var commonIndent = GetCommonIndent(block); + for (var i = 0; i < block.Lines.Count; i++) + { + var line = block.Lines.Lines[i]; + var slice = line.Slice; + var indent = CountIndentation(slice); + if (indent >= commonIndent) + slice.Start += commonIndent; + RenderCodeBlockLine(renderer, block, slice, i); + } + } + + private static void RenderCodeBlockLine(HtmlRenderer renderer, EnhancedCodeBlock block, StringSlice slice, int i) + { + renderer.WriteEscape(slice); + renderer.WriteLine(); + } + + private static int GetCommonIndent(EnhancedCodeBlock block) + { + var commonIndent = int.MaxValue; + for (var i = 0; i < block.Lines.Count; i++) + { + var line = block.Lines.Lines[i].Slice; + if (line.IsEmptyOrWhitespace()) continue; + var indent = CountIndentation(line); + commonIndent = Math.Min(commonIndent, indent); + } + return commonIndent; + } + + + private static int CountIndentation(StringSlice slice) + { + var indentCount = 0; + for (var i = slice.Start; i <= slice.End; i++) + { + var c = slice.Text[i]; + if (c == ' ') + indentCount++; + else if (c == '\t') + indentCount += TabWidth; + else + break; + } + return indentCount; + } + protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) { var callOuts = block.UniqueCallOuts; From 18a31aa753e7f98bd129f2ab4cf5201b7c8e336d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 27 Jan 2025 09:32:43 +0100 Subject: [PATCH 2/3] Format --- .../Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index b7f55ceba..92a747e2e 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -56,7 +56,8 @@ private static int GetCommonIndent(EnhancedCodeBlock block) for (var i = 0; i < block.Lines.Count; i++) { var line = block.Lines.Lines[i].Slice; - if (line.IsEmptyOrWhitespace()) continue; + if (line.IsEmptyOrWhitespace()) + continue; var indent = CountIndentation(line); commonIndent = Math.Min(commonIndent, indent); } From 7c36360ffb59ce907bb9e7d4384aff7d9d65d2e8 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Mon, 27 Jan 2025 09:41:57 +0100 Subject: [PATCH 3/3] Render callouts in code block --- .../EnhancedCodeBlockHtmlRenderer.cs | 15 +- .../Slices/Layout/_Scripts.cshtml | 2 +- src/Elastic.Markdown/_static/custom.css | 55 ++++++ src/Elastic.Markdown/_static/hljs.js | 186 ++++++++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/Elastic.Markdown/_static/hljs.js 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