Skip to content

Commit 2d7aab5

Browse files
committed
Fix asciidoctor callouts with highlight.js 11.x
See: * highlightjs/highlight.js#2889 * asciidoctor/asciidoctor#3976
1 parent 01f0270 commit 2d7aab5

File tree

1 file changed

+190
-0
lines changed

1 file changed

+190
-0
lines changed

layouts/partials/highlight-js.html

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,196 @@
88
{{ end }}
99
{{ end }}
1010
<script type="text/javascript">
11+
// highlight.js 11 removed the 'HTML auto-merging' internal plugin
12+
// however this is required for AsciiDoc to insert callouts
13+
// fortunately the issue description shows how to re-enable it, by registering the plugin
14+
// Copy-pasted code from https://github.com/highlightjs/highlight.js/issues/2889
15+
// See also
16+
var mergeHTMLPlugin = (function () {
17+
'use strict';
18+
19+
var originalStream;
20+
21+
/**
22+
* @param {string} value
23+
* @returns {string}
24+
*/
25+
function escapeHTML(value) {
26+
return value
27+
.replace(/&/g, '&amp;')
28+
.replace(/</g, '&lt;')
29+
.replace(/>/g, '&gt;')
30+
.replace(/"/g, '&quot;')
31+
.replace(/'/g, '&#x27;');
32+
}
33+
34+
/* plugin itself */
35+
36+
/** @type {HLJSPlugin} */
37+
const mergeHTMLPlugin = {
38+
// preserve the original HTML token stream
39+
"before:highlightElement": ({ el }) => {
40+
originalStream = nodeStream(el);
41+
},
42+
// merge it afterwards with the highlighted token stream
43+
"after:highlightElement": ({ el, result, text }) => {
44+
if (!originalStream.length) return;
45+
46+
const resultNode = document.createElement('div');
47+
resultNode.innerHTML = result.value;
48+
result.value = mergeStreams(originalStream, nodeStream(resultNode), text);
49+
el.innerHTML = result.value;
50+
}
51+
};
52+
53+
/* Stream merging support functions */
54+
55+
/**
56+
* @typedef Event
57+
* @property {'start'|'stop'} event
58+
* @property {number} offset
59+
* @property {Node} node
60+
*/
61+
62+
/**
63+
* @param {Node} node
64+
*/
65+
function tag(node) {
66+
return node.nodeName.toLowerCase();
67+
}
68+
69+
/**
70+
* @param {Node} node
71+
*/
72+
function nodeStream(node) {
73+
/** @type Event[] */
74+
const result = [];
75+
(function _nodeStream(node, offset) {
76+
for (let child = node.firstChild; child; child = child.nextSibling) {
77+
if (child.nodeType === 3) {
78+
offset += child.nodeValue.length;
79+
} else if (child.nodeType === 1) {
80+
result.push({
81+
event: 'start',
82+
offset: offset,
83+
node: child
84+
});
85+
offset = _nodeStream(child, offset);
86+
// Prevent void elements from having an end tag that would actually
87+
// double them in the output. There are more void elements in HTML
88+
// but we list only those realistically expected in code display.
89+
if (!tag(child).match(/br|hr|img|input/)) {
90+
result.push({
91+
event: 'stop',
92+
offset: offset,
93+
node: child
94+
});
95+
}
96+
}
97+
}
98+
return offset;
99+
})(node, 0);
100+
return result;
101+
}
102+
103+
/**
104+
* @param {any} original - the original stream
105+
* @param {any} highlighted - stream of the highlighted source
106+
* @param {string} value - the original source itself
107+
*/
108+
function mergeStreams(original, highlighted, value) {
109+
let processed = 0;
110+
let result = '';
111+
const nodeStack = [];
112+
113+
function selectStream() {
114+
if (!original.length || !highlighted.length) {
115+
return original.length ? original : highlighted;
116+
}
117+
if (original[0].offset !== highlighted[0].offset) {
118+
return (original[0].offset < highlighted[0].offset) ? original : highlighted;
119+
}
120+
121+
/*
122+
To avoid starting the stream just before it should stop the order is
123+
ensured that original always starts first and closes last:
124+
125+
if (event1 == 'start' && event2 == 'start')
126+
return original;
127+
if (event1 == 'start' && event2 == 'stop')
128+
return highlighted;
129+
if (event1 == 'stop' && event2 == 'start')
130+
return original;
131+
if (event1 == 'stop' && event2 == 'stop')
132+
return highlighted;
133+
134+
... which is collapsed to:
135+
*/
136+
return highlighted[0].event === 'start' ? original : highlighted;
137+
}
138+
139+
/**
140+
* @param {Node} node
141+
*/
142+
function open(node) {
143+
/** @param {Attr} attr */
144+
function attributeString(attr) {
145+
return ' ' + attr.nodeName + '="' + escapeHTML(attr.value) + '"';
146+
}
147+
// @ts-ignore
148+
result += '<' + tag(node) + [].map.call(node.attributes, attributeString).join('') + '>';
149+
}
150+
151+
/**
152+
* @param {Node} node
153+
*/
154+
function close(node) {
155+
result += '</' + tag(node) + '>';
156+
}
157+
158+
/**
159+
* @param {Event} event
160+
*/
161+
function render(event) {
162+
(event.event === 'start' ? open : close)(event.node);
163+
}
164+
165+
while (original.length || highlighted.length) {
166+
let stream = selectStream();
167+
result += escapeHTML(value.substring(processed, stream[0].offset));
168+
processed = stream[0].offset;
169+
if (stream === original) {
170+
/*
171+
On any opening or closing tag of the original markup we first close
172+
the entire highlighted node stack, then render the original tag along
173+
with all the following original tags at the same offset and then
174+
reopen all the tags on the highlighted stack.
175+
*/
176+
nodeStack.reverse().forEach(close);
177+
do {
178+
render(stream.splice(0, 1)[0]);
179+
stream = selectStream();
180+
} while (stream === original && stream.length && stream[0].offset === processed);
181+
nodeStack.reverse().forEach(open);
182+
} else {
183+
if (stream[0].event === 'start') {
184+
nodeStack.push(stream[0].node);
185+
} else {
186+
nodeStack.pop();
187+
}
188+
render(stream.splice(0, 1)[0]);
189+
}
190+
}
191+
return result + escapeHTML(value.substr(processed));
192+
}
193+
194+
return mergeHTMLPlugin;
195+
196+
}());
197+
hljs.addPlugin(mergeHTMLPlugin);
198+
// end of copy-pasted code
199+
200+
11201
{{ with $.Scratch.Get "hl_languages" }}
12202
hljs.configure({languages: [{{(delimit . ", ")}}]});
13203
{{ end }}

0 commit comments

Comments
 (0)