UNPKG

@tanstack/highlight

Version:

Tiny class-based syntax highlighting for documentation.

189 lines (188 loc) • 7.04 kB
import { renderTokenOutput, normalizeDataKey } from './internal/render.js'; 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); } 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; }