From 09cec7d6dd4adf958ffa05247049af4b175ea1c7 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Tue, 20 Sep 2022 20:01:14 -0300 Subject: [PATCH] FIX: Fix GitHub onebox syntax highlight (#18300) Highlight.js 11 deprecated the feature to highlight HTML blocks while keeping the HTML structure, which broke our GitHub onebox syntax highlight. This patch adds it back by bringing the maintainers code as a plugin. See https://github.com/highlightjs/highlight.js/issues/2889 --- .../lib/highlight-syntax-merge-html-plugin.js | 194 ++++++++++++++++++ .../discourse/app/lib/highlight-syntax.js | 2 + 2 files changed, 196 insertions(+) create mode 100644 app/assets/javascripts/discourse/app/lib/highlight-syntax-merge-html-plugin.js diff --git a/app/assets/javascripts/discourse/app/lib/highlight-syntax-merge-html-plugin.js b/app/assets/javascripts/discourse/app/lib/highlight-syntax-merge-html-plugin.js new file mode 100644 index 00000000000..727b61dfcc6 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/highlight-syntax-merge-html-plugin.js @@ -0,0 +1,194 @@ +// From https://github.com/highlightjs/highlight.js/issues/2889#issue-748412174 +/* eslint-disable */ +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; +})(); + +export default mergeHTMLPlugin; diff --git a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js index b8d0abef447..06767556fec 100644 --- a/app/assets/javascripts/discourse/app/lib/highlight-syntax.js +++ b/app/assets/javascripts/discourse/app/lib/highlight-syntax.js @@ -1,4 +1,5 @@ import loadScript from "discourse/lib/load-script"; +import mergeHTMLPlugin from "discourse/lib/highlight-syntax-merge-html-plugin"; /*global hljs:true */ let _moreLanguages = []; @@ -26,6 +27,7 @@ export default function highlightSyntax(elem, siteSettings, session) { return loadScript(path).then(() => { customHighlightJSLanguages(); + hljs.addPlugin(mergeHTMLPlugin); codeblocks.forEach((e) => { // Large code blocks can cause crashes or slowdowns