UNPKG

@tanstack/highlight

Version:

Tiny class-based syntax highlighting for documentation.

389 lines (388 loc) • 13.7 kB
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; }