import { decode } from 'html-entities';
import cheerio from 'cheerio';
import _ from 'lodash';
import { NodeOrText, MbNode } from '../utils/node.js';
import { markdownIt as md } from '../lib/markdown-it/index.js';
interface TraverseLinePartData {
numCharsTraversed: number,
shouldParentHighlight: boolean,
highlightRange?: number[],
}
/**
* Traverses a line part and applies highlighting if necessary.
* @param node The node of the line part to be traversed
* @param hlStart The highlight start position, relative to the start of the line part
* @param hlEnd The highlight end position, relative to the start of the line part
* @param color Optional color for the highlight
* @returns An object that contains data to be used by the node's parent.
*/
function traverseLinePart(
node: NodeOrText,
hlStart: number,
hlEnd: number,
color?: string,
): TraverseLinePartData {
const resData: TraverseLinePartData = {
numCharsTraversed: 0,
shouldParentHighlight: false,
highlightRange: undefined,
};
if (hlEnd <= 0) {
// Highlight end has passed, no need to traverse further
return resData;
}
if (node.type === 'text') {
/*
* Node is a text node. It is not an inherent HTML element of its own,
* so to actually highlight this text, we have to ask to apply at its parent.
*/
const cleanedText = decode(node.data);
const textLength = cleanedText.length;
resData.numCharsTraversed = textLength;
if (hlStart >= textLength) {
// Highlight start is not in this text
resData.shouldParentHighlight = false;
return resData;
}
if (hlStart <= 0 && hlEnd >= textLength) {
// Highlight spans across the entirety of text
resData.shouldParentHighlight = true;
return resData;
}
// Partial text highlighting
resData.shouldParentHighlight = true;
resData.highlightRange = [hlStart, hlEnd];
return resData;
}
if (!node.children) {
return resData;
}
/*
* The remaining possibility is that node is a tag node.
* It has at least one child (to contain the text content).
* It may have more children, such as inner tag nodes.
*/
const highlightData = node.children.map((child) => {
const [relativeHlStart, relativeHlEnd] = [hlStart, hlEnd].map(x => x - resData.numCharsTraversed);
const data = traverseLinePart(child, relativeHlStart, relativeHlEnd, color);
resData.numCharsTraversed += data.numCharsTraversed;
return data;
});
if (highlightData.every(data => data.shouldParentHighlight && !data.highlightRange)) {
/*
* Every child wants highlight to be applied to the whole content at node level.
* For conciseness, ask for the node's parent to highlight, if possible
*/
resData.shouldParentHighlight = true;
return resData;
}
/*
* If node level highlighting is not possible, highlight the individual children as needed.
* For text nodes, it is trickier, as we have to wrap the text inside a first.
* Essentially, we have to change the text node to become a tag node.
*/
node.children.forEach((child, idx) => {
const data = highlightData[idx];
if (!data.shouldParentHighlight) {
return;
}
if (child.type === 'tag') {
child.attribs = child.attribs ?? {};
if (color) {
child.attribs.style = child.attribs.style
? `${child.attribs.style} background-color: ${color};`
: `background-color: ${color};`;
return;
}
// Apply the 'highlighted' class if no color is provided
child.attribs.class = child.attribs.class
? `${child.attribs.class} highlighted`
: 'highlighted';
return;
}
if (!data.highlightRange) {
if (color) {
cheerio(child).wrap(``);
} else if (child.type === 'text') {
cheerio(child).wrap('');
}
} else {
const [start, end] = data.highlightRange;
const cleaned = decode(child.data);
const split = [cleaned.substring(0, start), cleaned.substring(start, end), cleaned.substring(end)];
const [pre, highlighted, post] = split.map(md.utils.escapeHtml);
if (color) {
const s = `${pre}${highlighted}${post}`;
const newElement = cheerio(s);
cheerio(child).replaceWith(newElement);
} else {
const str = `${pre}${highlighted}${post}`;
const newElement = cheerio(str);
cheerio(child).replaceWith(newElement);
}
}
});
resData.shouldParentHighlight = false;
return resData;
}
/**
* Applies pending highlighting to the code block.
* This looks into each line for highlighting data, and if found,
* traverses over the line and applies the highlight.
* @param node Root of the code block element, which is the 'pre' node
*/
export function highlightCodeBlock(node: MbNode) {
if (!node.children) {
return;
}
const codeNode = node.children.find(c => c.name === 'code');
if (!codeNode || (!codeNode.children)) {
return;
}
codeNode.children.forEach((lineNode) => {
if ((!lineNode.attribs) || !_.has(lineNode.attribs, 'hl-data')) {
return;
}
const bounds = lineNode.attribs['hl-data']
.split(',')
.map((boundStr) => {
const [range, color] = boundStr.split(':');
const [start, end] = range.split('-');
return [start, end, color];
});
bounds.forEach(([start, end, color]) => traverseLinePart(lineNode, Number(start), Number(end), color));
delete lineNode.attribs['hl-data'];
});
}
/**
* Adjust the class attribute of code blocks according to the global line numbers setting.
* Append the 'line-numbers' class if the global setting is true.
* @param node the code block element, which is the 'code' node
* @param showCodeLineNumbers true if line numbers should be shown, false otherwise
*/
export function setCodeLineNumbers(node: MbNode, showCodeLineNumbers: boolean) {
const existingClass = node.attribs.class || '';
const styleClassRegex = /(^|\s)(no-)?line-numbers($|\s)/;
const hasStyleClass = styleClassRegex.test(existingClass);
if (hasStyleClass) {
return;
}
if (showCodeLineNumbers) {
node.attribs = node.attribs ?? {};
node.attribs.class = `line-numbers${existingClass === '' ? '' : ` ${existingClass}`}`;
}
}