import { classHighlighter, highlightCode } from "@lezer/highlight"; import { getModeForPath, getModesByName } from "cm/modelist"; import { getThemeConfig } from "cm/themes"; import DOMPurify from "dompurify"; import settings from "lib/settings"; const highlightCache = new Map(); const MAX_CACHE_SIZE = 500; let styleElement = null; let currentThemeId = null; export function sanitize(text) { if (!text) return ""; return DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }); } function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function addSymbolHighlight(html, symbol) { if (!symbol) return html; const escapedSymbol = escapeRegExp(sanitize(symbol)); const regex = new RegExp(`(${escapedSymbol})`, "gi"); return html.replace(regex, '$1'); } function setCache(key, value) { if (highlightCache.size >= MAX_CACHE_SIZE) { const firstKey = highlightCache.keys().next().value; highlightCache.delete(firstKey); } highlightCache.set(key, value); } /** * Generates CSS styles for syntax highlighting tokens * @param {Object} config - Theme config with color values * @param {string} selector - CSS selector to scope styles * @param {boolean} includeBackground - Whether to include background/foreground base styles */ function generateStyles(config, selector, includeBackground = true) { const c = config; const keyword = c.keyword || "#c678dd"; const string = c.string || "#98c379"; const number = c.number || "#d19a66"; const comment = c.comment || "#5c6370"; const func = c.function || "#61afef"; const variable = c.variable || "#e06c75"; const type = c.type || "#e5c07b"; const className = c.class || type; const constant = c.constant || number; const operator = c.operator || keyword; const invalid = c.invalid || "#ff6b6b"; const foreground = c.foreground || "#abb2bf"; const background = c.background || "#282c34"; const baseStyles = includeBackground ? ` ${selector} { background: ${background}; color: ${foreground}; }` : ""; return `${baseStyles} ${selector} .tok-keyword { color: ${keyword}; } ${selector} .tok-operator { color: ${operator}; } ${selector} .tok-number { color: ${number}; } ${selector} .tok-string { color: ${string}; } ${selector} .tok-comment { color: ${comment}; font-style: italic; } ${selector} .tok-variableName { color: ${variable}; } ${selector} .tok-propertyName { color: ${func}; } ${selector} .tok-typeName { color: ${type}; } ${selector} .tok-className { color: ${className}; } ${selector} .tok-function { color: ${func}; } ${selector} .tok-bool { color: ${constant}; } ${selector} .tok-null { color: ${constant}; } ${selector} .tok-punctuation { color: ${foreground}; } ${selector} .tok-definition { color: ${variable}; } ${selector} .tok-labelName { color: ${variable}; } ${selector} .tok-namespace { color: ${type}; } ${selector} .tok-macroName { color: ${keyword}; } ${selector} .tok-atom { color: ${constant}; } ${selector} .tok-meta { color: ${foreground}; } ${selector} .tok-heading { color: ${variable}; font-weight: bold; } ${selector} .tok-link { color: ${func}; text-decoration: underline; } ${selector} .tok-strikethrough { text-decoration: line-through; } ${selector} .tok-emphasis { font-style: italic; } ${selector} .tok-strong { font-weight: bold; } ${selector} .tok-invalid { color: ${invalid}; } ${selector} .tok-name { color: ${variable}; } ${selector} .tok-deleted { color: ${invalid}; } ${selector} .tok-inserted { color: ${string}; } ${selector} .tok-changed { color: ${number}; } `.trim(); } /** * Injects dynamic CSS for syntax highlighting based on current editor theme */ function injectStyles() { const themeId = settings?.value?.editorTheme || "one_dark"; const config = getThemeConfig(themeId); // Code blocks need background, references panel uses parent's background const codeBlockStyles = generateStyles(config, ".cm-highlighted", true); const refPreviewStyles = generateStyles(config, ".ref-preview", false); const allStyles = `${codeBlockStyles}\n${refPreviewStyles}`; if (!styleElement) { styleElement = document.createElement("style"); styleElement.id = "cm-static-highlight-styles"; document.head.appendChild(styleElement); } styleElement.textContent = allStyles; currentThemeId = themeId; } /** * Gets the language parser for a given URI using the modelist */ async function getLanguageParser(uri) { const mode = getModeForPath(uri); if (!mode?.languageExtension) return null; try { const langExt = await mode.languageExtension(); if (!langExt) return null; const langArray = Array.isArray(langExt) ? langExt : [langExt]; for (const ext of langArray) { if (ext && typeof ext === "object" && "language" in ext) { return ext.language.parser; } } } catch (e) { console.warn("Failed to get language parser for", uri, e); } return null; } /** * Gets language parser by language name (e.g., "javascript", "python") * Uses modelist to find the mode and get first valid extension for file matching */ async function getParserForLanguage(langName) { if (!langName) return null; const modesByName = getModesByName(); const normalizedName = langName.toLowerCase(); // Try to find mode by name (case-insensitive) const mode = modesByName[normalizedName]; if (mode?.languageExtension) { try { const langExt = await mode.languageExtension(); if (!langExt) return null; const langArray = Array.isArray(langExt) ? langExt : [langExt]; for (const ext of langArray) { if (ext && typeof ext === "object" && "language" in ext) { return ext.language.parser; } } } catch (e) { console.warn("Failed to get parser for language:", langName, e); } } // Fallback: create a fake filename and use getModeForPath // This handles cases where the language name doesn't match mode name exactly const fakeUri = `file.${normalizedName}`; return await getLanguageParser(fakeUri); } /** * Highlights a single line of code for display in references panel * @param {string} text - The line of code to highlight * @param {string} uri - File URI for language detection * @param {string|null} symbolName - Optional symbol to highlight with special styling */ export async function highlightLine(text, uri, symbolName = null) { if (!text || !text.trim()) return ""; const themeId = settings?.value?.editorTheme || "one_dark"; const cacheKey = `line:${themeId}:${uri}:${text}:${symbolName || ""}`; if (highlightCache.has(cacheKey)) { return highlightCache.get(cacheKey); } const trimmedText = text.trim(); try { const parser = await getLanguageParser(uri); if (parser) { const tree = parser.parse(trimmedText); let result = ""; highlightCode( trimmedText, tree, classHighlighter, (code, classes) => { if (classes) { result += `${escapeHtml(code)}`; } else { result += escapeHtml(code); } }, () => {}, ); if (result) { const highlighted = symbolName ? addSymbolHighlight(result, symbolName) : result; setCache(cacheKey, highlighted); return highlighted; } } } catch (e) { console.warn("Highlighting failed for", uri, e); } const escaped = escapeHtml(trimmedText); const highlighted = symbolName ? addSymbolHighlight(escaped, symbolName) : escaped; setCache(cacheKey, highlighted); return highlighted; } /** * Highlights a code block for display in markdown/plugin pages * @param {string} code - The code to highlight * @param {string} language - Language identifier from markdown fence (e.g., "javascript", "python") */ export async function highlightCodeBlock(code, language) { if (!code) return ""; const themeId = settings?.value?.editorTheme || "one_dark"; const langKey = (language || "text").toLowerCase(); const cacheKey = `block:${themeId}:${langKey}:${code}`; if (highlightCache.has(cacheKey)) { return highlightCache.get(cacheKey); } try { const parser = await getParserForLanguage(langKey); if (parser) { const tree = parser.parse(code); let result = ""; highlightCode( code, tree, classHighlighter, (text, classes) => { if (classes) { result += `${escapeHtml(text)}`; } else { result += escapeHtml(text); } }, () => { result += "\n"; }, ); if (result) { setCache(cacheKey, result); return result; } } } catch (e) { console.warn("Code block highlighting failed for", language, e); } const escaped = escapeHtml(code); setCache(cacheKey, escaped); return escaped; } export function clearHighlightCache() { highlightCache.clear(); } /** * Initializes the static code highlighting system. * Injects theme-based CSS and sets up listener for theme changes. */ export function initHighlighting() { injectStyles(); settings.on("update:editorTheme:after", () => { const newThemeId = settings?.value?.editorTheme || "one_dark"; if (newThemeId !== currentThemeId) { injectStyles(); highlightCache.clear(); } }); } export default { sanitize, highlightLine, highlightCodeBlock, clearHighlightCache, initHighlighting, };