@tanstack/highlight
Version:
Tiny class-based syntax highlighting for documentation.
189 lines (188 loc) • 7.04 kB
JavaScript
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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
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;
}