@tanstack/highlight
Version:
Tiny class-based syntax highlighting for documentation.
389 lines (388 loc) • 13.7 kB
JavaScript
import { collectPatternRanges, offsetRanges } from './patterns.js';
const keywords = 'abstract|as|asserts|async|await|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|infer|instanceof|interface|is|keyof|let|module|namespace|new|of|override|package|private|protected|public|readonly|return|satisfies|set|static|super|switch|this|throw|try|type|typeof|using|var|while|with|yield';
const semanticPatterns = [
{
className: 'property',
regex: /(?<!\.)\.([A-Za-z_$][\w$]*)/g,
group: 1,
},
{ className: 'function', regex: /@[A-Za-z_$][\w$]*/g },
{
className: 'keyword',
regex: new RegExp(`\\b(?:${keywords})\\b`, 'g'),
},
{
className: 'literal',
regex: /\b(?:false|null|true|undefined|NaN|Infinity)\b/g,
},
{
className: 'number',
regex: /\b(?:0x[\da-f](?:_?[\da-f])*n?|0b[01](?:_?[01])*n?|0o[0-7](?:_?[0-7])*n?|\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:e[+-]?\d(?:_?\d)*)?n?)\b/gi,
},
{
className: 'function',
regex: /\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)/g,
group: 1,
},
{
className: 'function',
regex: /(^|[^.A-Za-z0-9_$])([A-Za-z_$][\w$]*)(?=\s*\()/g,
group: 2,
},
{
className: 'type',
regex: /\b(?:Array|Record|Promise|Readonly|Set|Map|WeakMap|WeakSet|string|number|boolean|bigint|symbol|object|unknown|never|void|any|[A-Z][A-Za-z0-9_$]*)\b/g,
},
];
export function collectScriptRanges(code, jsx = false, jsxAtLineStart = false) {
const jsxText = jsx ? new Uint8Array(code.length) : undefined;
const initial = [];
collectScriptInitialRanges(code, jsx, jsxAtLineStart, jsxText, initial);
return collectPatternRanges(code, semanticPatterns, initial, jsxText);
}
function collectScriptInitialRanges(code, jsx, jsxAtLineStart, jsxText, ranges, index = 0, limit = code.length, tagBody = false) {
const objectBraces = [];
const expressions = [];
let jsxDepth = 0;
while (index < limit) {
const expression = expressions.at(-1);
const inJsxText = jsxDepth > (expression || 0);
const character = code[index];
const next = code[index + 1];
if (inJsxText) {
if (character === '{') {
expressions.push(jsxDepth);
index++;
continue;
}
if (character !== '<') {
const start = index;
while (index < code.length && code[index] !== '<' && code[index] !== '{') {
index++;
}
jsxText.fill(1, start, index);
continue;
}
}
if (tagBody &&
!expressions.length &&
!jsxDepth &&
character === '>') {
return index;
}
if (character === '/' && next === '/') {
const end = findLineEnd(code, index + 2);
ranges.push({ start: index, end, className: 'comment' });
index = end;
continue;
}
if (character === '/' && next === '*') {
const close = code.indexOf('*/', index + 2);
const end = close < 0 ? code.length : close + 2;
ranges.push({ start: index, end, className: 'comment' });
index = end;
continue;
}
if (character === "'" || character === '"') {
const end = findQuotedEnd(code, index, character, tagBody && !expressions.length);
ranges.push({ start: index, end, className: 'string' });
index = end;
continue;
}
if (character === '`') {
index = collectTemplateRanges(code, index, ranges, jsx, jsxAtLineStart);
continue;
}
if (character === '/' &&
!(jsx && code[index - 1] === '<') &&
isRegexStart(code, index)) {
const end = findRegexEnd(code, index);
if (end > index + 1) {
ranges.push({ start: index, end, className: 'literal' });
index = end;
continue;
}
}
if (character === '{') {
const before = previousCodeIndex(code, index, ranges);
let wordStart = before;
while (wordStart >= 0 && /[\w$]/.test(code[wordStart]))
wordStart--;
const word = code.slice(wordStart + 1, before + 1);
objectBraces.push('=(:,['.includes(code[before] || '\0') ||
/^(?:return|yield|const|let|var)$/.test(word));
}
else if (character === '}') {
objectBraces.pop();
}
else if (objectBraces.at(-1) && /[A-Za-z_$]/.test(character)) {
const before = previousCodeIndex(code, index, ranges);
if ('{,;'.includes(code[before] || '\0')) {
let end = index + 1;
while (end < limit && /[\w$]/.test(code[end]))
end++;
const after = end + (code.slice(end).match(/^(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*(?:\n|$))*/)?.[0].length || 0);
if (code[after] === ':') {
ranges.push({ start: index, end, className: 'property' });
index = end;
continue;
}
}
}
if (!jsx) {
index++;
continue;
}
if (!inJsxText &&
character === '{' &&
(expressions.length || tagBody)) {
expressions.push(expressions.length ? expression : jsxDepth);
index++;
continue;
}
if (!inJsxText && expressions.length && character === '}') {
expressions.pop();
index++;
continue;
}
if (character !== '<') {
index++;
continue;
}
if (code.startsWith('</>', index)) {
jsxDepth--;
index += 3;
continue;
}
if (code.startsWith('<>', index) &&
(inJsxText || isJsxStart(code, index, jsxAtLineStart))) {
jsxDepth++;
index += 2;
continue;
}
const closing = code[index + 1] === '/';
const nameStart = index + (closing ? 2 : 1);
const nameMatch = /^[A-Za-z][\w:.-]*/.exec(code.slice(nameStart));
if (!nameMatch ||
(!closing &&
!inJsxText &&
!isJsxStart(code, index, jsxAtLineStart))) {
index++;
continue;
}
const end = collectScriptInitialRanges(code, true, jsxAtLineStart, jsxText, ranges, nameStart + nameMatch[0].length, limit, true);
if (end < 0 || isTypeParameter(code, index, end, closing)) {
index++;
continue;
}
ranges.push({
start: nameStart,
end: nameStart + nameMatch[0].length,
className: 'tag',
});
if (!closing) {
const attributeStart = nameStart + nameMatch[0].length;
collectJsxAttributes(code, attributeStart, end, ranges);
}
const selfClosing = /\/\s*$/.test(code.slice(nameStart, end));
if (closing)
jsxDepth--;
else if (!selfClosing)
jsxDepth++;
index = end + 1;
}
return -1;
}
function collectTemplateRanges(code, start, ranges, jsx, jsxAtLineStart) {
let segmentStart = start;
let index = start + 1;
while (index < code.length) {
if (code[index] === '\\') {
index += 2;
continue;
}
if (code[index] === '`') {
ranges.push({ start: segmentStart, end: index + 1, className: 'string' });
return index + 1;
}
if (code[index] === '$' && code[index + 1] === '{') {
if (segmentStart < index) {
ranges.push({ start: segmentStart, end: index, className: 'string' });
}
ranges.push({ start: index, end: index + 2, className: 'operator' });
const end = findInterpolationEnd(code, index + 2);
const expressionEnd = end < 0 ? code.length : end;
const expression = code.slice(index + 2, expressionEnd);
ranges.push(...offsetRanges(collectScriptRanges(expression, jsx, jsxAtLineStart), index + 2));
if (end < 0)
return code.length;
ranges.push({ start: end, end: end + 1, className: 'operator' });
index = end + 1;
segmentStart = index;
continue;
}
index++;
}
if (segmentStart < code.length) {
ranges.push({ start: segmentStart, end: code.length, className: 'string' });
}
return code.length;
}
function findInterpolationEnd(code, start) {
let depth = 1;
let index = start;
while (index < code.length) {
const character = code[index];
const next = code[index + 1];
if (character === "'" || character === '"') {
index = findQuotedEnd(code, index, character);
continue;
}
if (character === '`') {
index = skipTemplate(code, index);
continue;
}
if (character === '/' && next === '/') {
index = findLineEnd(code, index + 2);
continue;
}
if (character === '/' && next === '*') {
const close = code.indexOf('*/', index + 2);
index = close < 0 ? code.length : close + 2;
continue;
}
if (character === '/' && isRegexStart(code, index)) {
index = findRegexEnd(code, index);
continue;
}
if (character === '{')
depth++;
if (character === '}' && --depth === 0)
return index;
index++;
}
return -1;
}
function skipTemplate(code, start) {
let index = start + 1;
while (index < code.length) {
if (code[index] === '\\')
index += 2;
else if (code[index] === '`')
return index + 1;
else if (code[index] === '$' && code[index + 1] === '{') {
const end = findInterpolationEnd(code, index + 2);
index = end < 0 ? code.length : end + 1;
}
else
index++;
}
return code.length;
}
function collectJsxAttributes(code, start, end, ranges) {
const source = code.slice(start, end);
const regex = /\s([:@A-Za-z_$][\w$:.-]*)(?=\s*(?:=|\/?>))/g;
let match;
while ((match = regex.exec(source))) {
const attributeStart = start + match.index + 1;
ranges.push({
start: attributeStart,
end: attributeStart + match[1].length,
className: 'attr',
});
}
}
function isJsxStart(code, index, jsxAtLineStart) {
if (jsxAtLineStart &&
/^\s*$/.test(code.slice(code.lastIndexOf('\n', index - 1) + 1, index))) {
return true;
}
const before = code.slice(0, index).trimEnd();
if (!before)
return true;
if (/\b(?:return|case|yield)$/.test(before))
return true;
return /(?:[=([{,:;!&|?]|>)$/.test(before);
}
function isTypeParameter(code, start, end, closing) {
if (closing)
return false;
const inside = code.slice(start + 1, end);
const after = code.slice(end + 1).trimStart();
return (/\s+extends\s+/.test(inside) ||
(after[0] === '(' && !inside.trimEnd().endsWith('/')));
}
function isRegexStart(code, index) {
const before = code.slice(0, index).trimEnd();
if (!before)
return true;
if (/\b(?:case|delete|in|instanceof|of|return|throw|typeof|void|yield)$/.test(before)) {
return true;
}
return /[=([{,:;!&|?~+*%^<>-]$/.test(before);
}
function findRegexEnd(code, start) {
let inClass = false;
let index = start + 1;
while (index < code.length && code[index] !== '\n') {
if (code[index] === '\\')
index += 2;
else if (code[index] === '[') {
inClass = true;
index++;
}
else if (code[index] === ']') {
inClass = false;
index++;
}
else if (code[index] === '/' && !inClass) {
index++;
while (/[a-z]/i.test(code[index] || ''))
index++;
return index;
}
else
index++;
}
return start + 1;
}
function findQuotedEnd(code, start, quote, multiline = false) {
let index = start + 1;
while (index < code.length) {
if (code[index] === '\\')
index += 2;
else if (code[index] === quote)
return index + 1;
else if (!multiline && code[index] === '\n')
return index;
else
index++;
}
return code.length;
}
function findLineEnd(code, start) {
const end = code.indexOf('\n', start);
return end < 0 ? code.length : end;
}
// Comments do not change the syntactic position of a property or opening brace.
function previousCodeIndex(code, index, ranges) {
let before = index - 1;
let rangeIndex = ranges.length - 1;
while (before >= 0) {
if (/\s/.test(code[before])) {
before--;
continue;
}
const range = ranges[rangeIndex];
if (range && range.end > before) {
rangeIndex--;
if (range.className === 'comment' && range.start <= before) {
before = range.start - 1;
continue;
}
}
else
break;
}
return before;
}