UNPKG

@tanstack/highlight

Version:

Tiny class-based syntax highlighting for documentation.

299 lines (298 loc) • 11.6 kB
export function defineLanguage(definition) { return definition; } export function createHighlighter({ fallbackLanguage = 'plaintext', languages, }) { const definitions = new Map(); const aliases = new Map(); for (const definition of languages) { const name = normalizeName(definition.name); definitions.set(name, definition); aliases.set(name, name); for (const alias of definition.aliases || []) { aliases.set(normalizeName(alias), name); } } const fallback = normalizeName(fallbackLanguage); function normalizeLanguage(lang) { return aliases.get(normalizeName(lang || fallback)) || fallback; } function tokenizeRanges(code, lang, depth = 0) { const definition = definitions.get(normalizeLanguage(lang)); if (!definition || depth > 24) return []; const context = { hasLanguage(candidate) { const resolved = aliases.get(normalizeName(candidate)); return Boolean(resolved && definitions.has(resolved)); }, tokenize(embeddedCode, embeddedLanguage) { return normalizeTokenRanges(embeddedCode.length, tokenizeRanges(embeddedCode, embeddedLanguage, depth + 1)); }, }; return definition.tokenize(code, context); } function tokenize(code, options = {}) { const lang = normalizeLanguage(options.lang); const ranges = tokenizeRanges(code, lang); return { code, lang, tokens: normalizeTokenRanges(code.length, ranges, code), }; } function highlight(code, options = {}) { const result = tokenize(code, options); // Plain blocks do not need an intermediate render tree. Keep the same // token result and escaping contract as the decorated rendering path. let innerHtml = ''; if (!options.lineNumbers && !options.decorations?.length) { for (const token of result.tokens) { const value = escapeHtml(token.value); innerHtml += token.className ? `<span class="th-token th-${escapeHtml(token.className)}">${value}</span>` : value; } } else { innerHtml = concatHtml(renderTokenOutput(result.tokens, options, htmlRenderer)); } return { ...result, html: `<pre class="th-code th-code--${escapeHtml(result.lang)}${options.lineNumbers ? ' th-code--line-numbers' : ''}" data-language="${escapeHtml(result.lang)}"><code>${innerHtml}</code></pre>`, }; } function renderCodeBlockData({ code, decorations, lang, lineNumbers, title, }) { const copyText = code.trimEnd(); const result = highlight(copyText, { decorations, lang, lineNumbers }); return { copyText, htmlMarkup: result.html, lang: result.lang, title, tokens: result.tokens, }; } return { highlight, highlightToHtml(code, options = {}) { return highlight(code, options).html; }, listLanguages() { return [...definitions.keys()]; }, normalizeLanguage, renderCodeBlockData, tokenize, }; } const nodeRenderer = { text: value => ({ type: 'text', value }), token: (className, child) => ({ type: 'element', classNames: ['th-token', `th-${className}`], children: [child], }), element: (classNames, data, children) => ({ type: 'element', classNames, data, children }), }; const htmlRenderer = { text: escapeHtml, token: (className, child) => `<span class="th-token th-${escapeHtml(className)}">${child}</span>`, element(classNames, attributes, children) { const className = escapeHtml(classNames.join(' ')); let data = ''; // stringifyData and the line wrapper supply normalized attribute names. if (attributes) for (const [key, value] of Object.entries(attributes)) { data += ` data-${key}="${escapeHtml(value)}"`; } return `<span${className ? ` class="${className}"` : ''}${data}>${concatHtml(children)}</span>`; }, }; function concatHtml(chunks) { let html = chunks[0] || ''; for (let index = 1; index < chunks.length; index++) html += chunks[index]; return html; } export function renderTokens(tokens, options = {}) { return renderTokenOutput(tokens, options, nodeRenderer); } 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; } export function renderNodesToHtml(nodes) { let html = ''; for (const node of nodes) { if (node.type === 'text') { html += escapeHtml(node.value); continue; } const className = escapeHtml(node.classNames.join(' ')); let data = ''; if (node.data) { for (const [key, value] of Object.entries(node.data)) { data += ` data-${normalizeDataKey(key)}="${escapeHtml(value)}"`; } } html += `<span${className ? ` class="${className}"` : ''}${data}>${renderNodesToHtml(node.children)}</span>`; } return html; } const htmlEscapes = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', }; export function escapeHtml(value) { return /[&<>"']/.test(value) ? value.replace(/[&<>"']/g, (character) => htmlEscapes[character]) : value; } function normalizeName(value) { return value.trim().toLowerCase(); } function normalizeTokenRanges(codeLength, input, code) { const ranges = []; let sorted = true; let previousStart = 0; for (const candidate of input) { if (!Number.isInteger(candidate.start) || !Number.isInteger(candidate.end)) continue; // Other out-of-bounds offsets cannot survive the empty-range check. const start = Math.max(0, candidate.start); const end = Math.min(codeLength, candidate.end); if (start >= end) continue; if (start < previousStart) sorted = false; previousStart = start; ranges.push({ className: candidate.className, start, end }); } if (!sorted) ranges.sort((a, b) => a.start - b.start); const result = []; let end = 0; for (const candidate of ranges) { const start = Math.max(0, candidate.start); if (start < end) continue; if (code !== undefined && start > end) result.push({ value: code.slice(end, start) }); end = candidate.end; result.push(code === undefined ? { className: candidate.className, start, end } : { className: candidate.className, value: code.slice(start, end) }); } if (code !== undefined && (end < codeLength || !result.length)) { result.push({ value: code.slice(end) }); } return result; } 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), ])); } 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) || []; }