@tanstack/highlight
Version:
Tiny class-based syntax highlighting for documentation.
112 lines (111 loc) • 4.69 kB
JavaScript
export function renderTokenOutput(tokens, options, renderer) {
const rangeDecorations = [];
const lineDecorations = [];
for (const decoration of options.decorations || []) {
if (decoration.range)
rangeDecorations.push(decoration);
else
lineDecorations.push(decoration);
}
const wrapLines = Boolean(options.lineNumbers || lineDecorations.length);
const cursor = { index: 0, offset: 0, boundaries: [0, 0] };
if (!wrapLines) {
return renderTokenSlice(tokens, 0, Infinity, rangeDecorations, cursor, renderer);
}
const code = tokens.map((token) => token.value).join('');
const nodes = [];
const lineStarts = getLineStarts(code);
for (let index = 0; index < lineStarts.length; index++) {
const line = index + 1;
const start = lineStarts[index];
const nextStart = lineStarts[index + 1] ?? code.length;
const hasNewline = nextStart > start && code[nextStart - 1] === '\n';
const end = hasNewline ? nextStart - 1 : nextStart;
const active = lineDecorations.filter((decoration) => includesLine(decoration.lines, line));
nodes.push(renderer.element([
'th-line',
...active.flatMap((decoration) => splitClassNames(decoration.className)),
], { ...mergeData(active), line: String(line) }, renderTokenSlice(tokens, start, end, rangeDecorations, cursor, renderer)));
if (hasNewline)
nodes.push(renderer.text('\n'));
}
return nodes;
}
function renderTokenSlice(tokens, start, end, decorations, cursor, renderer) {
const nodes = [];
for (; cursor.index < tokens.length; cursor.index++) {
const token = tokens[cursor.index];
const tokenStart = cursor.offset;
const tokenEnd = tokenStart + token.value.length;
const sliceStart = Math.max(start, tokenStart);
const sliceEnd = Math.min(end, tokenEnd);
if (sliceStart < sliceEnd) {
let sorted = cursor.boundaries;
sorted[0] = sliceStart;
sorted[1] = sliceEnd;
let boundaries;
for (const decoration of decorations) {
const [decorationStart, decorationEnd] = decoration.range;
if (decorationEnd <= sliceStart || decorationStart >= sliceEnd)
continue;
boundaries ||= new Set(sorted);
boundaries.add(Math.max(sliceStart, decorationStart)).add(Math.min(sliceEnd, decorationEnd));
}
if (boundaries)
sorted = [...boundaries].sort((a, b) => a - b);
for (let index = 0; index < sorted.length - 1; index++) {
const segmentStart = sorted[index];
const segmentEnd = sorted[index + 1];
let node = renderer.text(token.value.slice(segmentStart - tokenStart, segmentEnd - tokenStart));
if (token.className) {
node = renderer.token(token.className, node);
}
for (const decoration of decorations) {
if (decoration.range[0] > segmentStart ||
decoration.range[1] < segmentEnd)
continue;
node = renderer.element(['th-decoration', ...splitClassNames(decoration.className)], stringifyData(decoration.data), [node]);
}
nodes.push(node);
}
}
// Keep a token that crosses the line boundary for the next slice.
if (tokenEnd > end)
break;
cursor.offset = tokenEnd;
}
return nodes;
}
function getLineStarts(code) {
const starts = [0];
for (let index = code.indexOf('\n'); index >= 0 && index + 1 < code.length; index = code.indexOf('\n', index + 1)) {
starts.push(index + 1);
}
return starts;
}
function includesLine(lines, line) {
return typeof lines === 'number'
? lines === line
: line >= lines[0] && line <= lines[1];
}
function mergeData(decorations) {
const data = {};
for (const decoration of decorations) {
Object.assign(data, stringifyData(decoration.data));
}
return data;
}
function stringifyData(data) {
if (!data)
return undefined;
return Object.fromEntries(Object.entries(data).map(([key, value]) => [
normalizeDataKey(key),
String(value),
]));
}
export function normalizeDataKey(value) {
return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`).replace(/[^a-z0-9_.:-]/gi, '-');
}
function splitClassNames(className) {
return className?.trim().split(/\s+/).filter(Boolean) || [];
}