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}`}`; } }