From 4f202274db36f77a1d08509df617337675f68ac7 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 20:08:14 +0100 Subject: [PATCH] feat: replace PrismJS with highlight.js for code syntax highlighting PrismJS's CJS global state is fundamentally incompatible with Vite's ESM dev mode, causing missing language support (~40% of dropdown languages) and fragile loading hacks (optimizeDeps, global assignment). Replace the entire Prism integration with highlight.js/lib/common which is ESM-native, has no global state, and covers all 22 dropdown languages out of the box (~37 common languages included). Implementation: - Custom CodeHighlightPlugin using hljs.highlight() for tokenization - HTML parser extracts flat tokens from hljs output (handles nested spans) - Token type mapping from hljs scopes to Lexical codeHighlight theme keys - Selection preservation via absolute character offset save/restore - Re-entrancy guard prevents infinite transform loops - Removed @lexical/code-prism import and Vite optimizeDeps workaround Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bun.lock | 6 +- electron.vite.config.ts | 6 - package.json | 2 +- src/renderer/components/MarkdownComposer.tsx | 258 ++++++++++++++++++- 4 files changed, 250 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 15e58fd..cf11be8 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@lexical/react": "0.42.0", "@lexical/rich-text": "0.42.0", "@xyflow/react": "^12.10.1", + "highlight.js": "^11.11.1", "keytar": "^7.9.0", "lexical": "0.42.0", "lucide-react": "^0.577.0", @@ -25,7 +26,6 @@ "@lexical/headless": "0.42.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", - "@types/prismjs": "^1.26.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "5.1.0", @@ -339,8 +339,6 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -523,6 +521,8 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 0657da2..d1303ea 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -33,12 +33,6 @@ export default defineConfig({ build: { outDir: 'dist/renderer', }, - optimizeDeps: { - // @lexical/code-prism depends on prismjs + its language components via - // CJS require(). Vite must pre-bundle them together so that the Prism - // global is available when component IIFEs execute. - include: ['prismjs', '@lexical/code-prism'], - }, plugins: [react(), tailwindcss()], resolve: { alias: { diff --git a/package.json b/package.json index 14ca2a0..2af20d8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@lexical/headless": "0.42.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", - "@types/prismjs": "^1.26.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "5.1.0", @@ -56,6 +55,7 @@ "@lexical/react": "0.42.0", "@lexical/rich-text": "0.42.0", "@xyflow/react": "^12.10.1", + "highlight.js": "^11.11.1", "keytar": "^7.9.0", "lexical": "0.42.0", "lucide-react": "^0.577.0", diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index 5ac82a9..59b2f20 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -20,8 +20,15 @@ import { $convertFromMarkdownString, $convertToMarkdownString, } from '@lexical/markdown'; -import { $isCodeNode, $createCodeNode, CodeNode } from '@lexical/code'; -import { registerCodeHighlighting } from '@lexical/code-prism'; +import { + $isCodeNode, + $createCodeNode, + $createCodeHighlightNode, + $isCodeHighlightNode, + CodeNode, + CodeHighlightNode, +} from '@lexical/code'; +import hljs from 'highlight.js/lib/common'; import { $isListNode, INSERT_ORDERED_LIST_COMMAND, @@ -30,17 +37,22 @@ import { } from '@lexical/list'; import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text'; import { + $createLineBreakNode, $createParagraphNode, + $createTabNode, $createTextNode, $getNodeByKey, $getRoot, $getSelection, + $isLineBreakNode, $isRangeSelection, + $isTextNode, CLEAR_EDITOR_COMMAND, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, PASTE_COMMAND, + TextNode, type EditorThemeClasses, type LexicalEditor, } from 'lexical'; @@ -79,23 +91,15 @@ const editorTheme: EditorThemeClasses = { attr: 'mc-tok-attr', boolean: 'mc-tok-boolean', builtin: 'mc-tok-builtin', - cdata: 'mc-tok-comment', - char: 'mc-tok-string', - class: 'mc-tok-function', 'class-name': 'mc-tok-function', comment: 'mc-tok-comment', constant: 'mc-tok-constant', deleted: 'mc-tok-deleted', - doctype: 'mc-tok-comment', - entity: 'mc-tok-symbol', function: 'mc-tok-function', - important: 'mc-tok-keyword', inserted: 'mc-tok-inserted', keyword: 'mc-tok-keyword', - namespace: 'mc-tok-namespace', number: 'mc-tok-number', operator: 'mc-tok-operator', - prolog: 'mc-tok-comment', property: 'mc-tok-property', punctuation: 'mc-tok-punctuation', regex: 'mc-tok-regex', @@ -179,12 +183,242 @@ function EditorRefPlugin({ editorRef }: { editorRef: React.MutableRefObject = { + keyword: 'keyword', + built_in: 'builtin', + type: 'class-name', + literal: 'boolean', + number: 'number', + operator: 'operator', + punctuation: 'punctuation', + property: 'property', + regexp: 'regex', + string: 'string', + char: 'string', + subst: 'variable', + symbol: 'symbol', + class: 'class-name', + function: 'function', + variable: 'variable', + title: 'function', + params: 'variable', + comment: 'comment', + doctag: 'comment', + meta: 'comment', + tag: 'tag', + name: 'tag', + attr: 'attr', + attribute: 'attr', + 'selector-tag': 'selector', + 'selector-id': 'selector', + 'selector-class': 'selector', + 'template-tag': 'keyword', + 'template-variable': 'variable', + addition: 'inserted', + deletion: 'deleted', + section: 'keyword', + bullet: 'symbol', + link: 'url', + quote: 'string', +}; + +function mapHljsType(hljsType: string | undefined): string | undefined { + if (!hljsType) return undefined; + const primary = hljsType.split(' ')[0]; + return HLJS_TYPE_MAP[primary] ?? primary; +} + +interface HljsToken { + text: string; + type?: string; +} + +function tokenizeWithHljs(code: string, language: string): HljsToken[] { + try { + if (!language || !hljs.getLanguage(language)) return [{ text: code }]; + const result = hljs.highlight(code, { language, ignoreIllegals: true }); + return parseHljsHtml(result.value); + } catch { + return [{ text: code }]; + } +} + +function parseHljsHtml(html: string): HljsToken[] { + const tokens: HljsToken[] = []; + const typeStack: Array = [undefined]; + let pos = 0; + + while (pos < html.length) { + if (html[pos] === '<') { + if (html.startsWith('', pos)) { + typeStack.pop(); + pos += 7; + continue; + } + const openMatch = /^/i.exec(html.slice(pos)); + if (openMatch) { + typeStack.push(openMatch[1]); + pos += openMatch[0].length; + continue; + } + // Unknown tag — treat '<' as text + const currentType = typeStack[typeStack.length - 1]; + const prev = tokens[tokens.length - 1]; + if (prev && prev.type === currentType) prev.text += '<'; + else tokens.push({ text: '<', type: currentType }); + pos++; + } else { + const nextTag = html.indexOf('<', pos); + const raw = nextTag === -1 ? html.slice(pos) : html.slice(pos, nextTag); + if (raw) { + const text = raw + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); + const currentType = typeStack[typeStack.length - 1]; + const prev = tokens[tokens.length - 1]; + if (prev && prev.type === currentType) prev.text += text; + else tokens.push({ text, type: currentType }); + } + pos = nextTag === -1 ? html.length : nextTag; + } + } + + return tokens; +} + +/* ── Code highlight plugin ────────────────────────────── */ + +function getAbsoluteOffset( + codeNode: ReturnType, + point: { key: string; offset: number }, +): number { + if (!codeNode || !('getChildren' in codeNode)) return 0; + let offset = 0; + for (const child of (codeNode as CodeNode).getChildren()) { + if (child.getKey() === point.key) return offset + point.offset; + offset += $isLineBreakNode(child) ? 1 : child.getTextContentSize(); + } + return offset; +} + +function restoreSelectionFromOffsets(codeNode: CodeNode, anchorOff: number, focusOff: number) { + const children = codeNode.getChildren(); + + function findPoint(target: number) { + let offset = 0; + for (const child of children) { + const size = $isLineBreakNode(child) ? 1 : child.getTextContentSize(); + if (offset + size > target || (offset + size === target && $isTextNode(child))) { + return { + key: child.getKey(), + offset: target - offset, + type: ($isTextNode(child) || $isCodeHighlightNode(child) ? 'text' : 'element') as 'text' | 'element', + }; + } + offset += size; + } + const last = children[children.length - 1]; + if (last) { + return { + key: last.getKey(), + offset: $isLineBreakNode(last) ? 0 : last.getTextContentSize(), + type: ($isTextNode(last) || $isCodeHighlightNode(last) ? 'text' : 'element') as 'text' | 'element', + }; + } + return { key: codeNode.getKey(), offset: 0, type: 'element' as const }; + } + + const anchor = findPoint(anchorOff); + const focus = findPoint(focusOff); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.anchor.set(anchor.key, anchor.offset, anchor.type); + selection.focus.set(focus.key, focus.offset, focus.type); + } +} + +/** Enables highlight.js-based syntax highlighting inside CodeNodes. */ function CodeHighlightPlugin() { const [editor] = useLexicalComposerContext(); + useEffect(() => { - return registerCodeHighlighting(editor); + const highlightingKeys = new Set(); + + function highlightCode(codeNode: CodeNode) { + const nodeKey = codeNode.getKey(); + if (highlightingKeys.has(nodeKey)) return; + + const language = codeNode.getLanguage() || ''; + const code = codeNode.getTextContent(); + const tokens = tokenizeWithHljs(code, language); + + highlightingKeys.add(nodeKey); + editor.update(() => { + const current = $getNodeByKey(nodeKey); + if (!$isCodeNode(current) || !current.isAttached()) return; + + // Save cursor as absolute character offset + const sel = $getSelection(); + let anchorOff: number | undefined; + let focusOff: number | undefined; + if ($isRangeSelection(sel)) { + anchorOff = getAbsoluteOffset(current, sel.anchor); + focusOff = getAbsoluteOffset(current, sel.focus); + } + + // Build new children from tokens + current.clear(); + for (const token of tokens) { + const highlightType = mapHljsType(token.type); + const lines = token.text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (i > 0) current.append($createLineBreakNode()); + const tabParts = lines[i].split('\t'); + for (let j = 0; j < tabParts.length; j++) { + if (j > 0) current.append($createTabNode()); + if (tabParts[j].length > 0) { + current.append($createCodeHighlightNode(tabParts[j], highlightType)); + } + } + } + } + + // Restore cursor + if (anchorOff !== undefined && focusOff !== undefined) { + restoreSelectionFromOffsets(current, anchorOff, focusOff); + } + }); + queueMicrotask(() => highlightingKeys.delete(nodeKey)); + } + + const r1 = editor.registerNodeTransform(CodeNode, highlightCode); + const r2 = editor.registerNodeTransform(TextNode, (node) => { + const parent = node.getParent(); + if ($isCodeNode(parent)) highlightCode(parent); + }); + const r3 = editor.registerNodeTransform(CodeHighlightNode, (node) => { + const parent = node.getParent(); + if ($isCodeNode(parent)) { + highlightCode(parent); + } else if (parent) { + node.replace($createTextNode(node.getTextContent())); + } + }); + + return () => { + r1(); + r2(); + r3(); + }; }, [editor]); + return null; }