From cc684ab6896337d32fc6d258f16202a808a9edd1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 20:57:13 +0100 Subject: [PATCH] Highlight Assistant code blocks once the turn settles (OpenCode parity) OpenCode syntax-highlights code in the transcript; Pulse rendered fenced blocks as flat text. Assistant answers are full of shell commands, configs, and query output, so add highlight.js with an infra grammar set (bash, json, yaml, ini, sql, dockerfile, nginx): - Lazy-loaded into its own vendor-highlight chunk; the entry chunk stays clean and the chunk only downloads with the rest of the preloaded app shell. - Highlighting runs over the sanitized DOM after the turn settles, so generated spans never pass through DOMPurify, streaming morphs are never fought, and the class allowlist stays closed to the model. The single carve-out is marked's fence-language hint, pattern-pinned to language-x on only. - Token palette maps hljs classes to theme colors readable on the dark prose-pre background both app themes share. Per-block copy is intentionally not added: OpenCode has none, and the answer-level copy already exists. --- frontend-modern/package-lock.json | 10 +++ frontend-modern/package.json | 1 + .../src/components/AI/Chat/MessageItem.tsx | 8 ++ .../AI/Chat/__tests__/aiCodeHighlight.test.ts | 51 +++++++++++ .../src/components/AI/Chat/aiCodeHighlight.ts | 87 +++++++++++++++++++ .../AI/__tests__/aiChatUtils.test.ts | 17 ++++ .../src/components/AI/aiChatUtils.ts | 13 +++ frontend-modern/src/index.css | 41 +++++++++ frontend-modern/vite.config.ts | 4 + 9 files changed, 232 insertions(+) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/aiCodeHighlight.test.ts create mode 100644 frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index ffcc1e927..539542e7a 100644 --- a/frontend-modern/package-lock.json +++ b/frontend-modern/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@solidjs/router": "^0.10.10", "dompurify": "^3.4.1", + "highlight.js": "^11.11.1", "lucide-solid": "^0.545.0", "marked": "^17.0.1", "qrcode": "^1.5.4", @@ -4219,6 +4220,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 6e1314530..ebd34d507 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -44,6 +44,7 @@ "dependencies": { "@solidjs/router": "^0.10.10", "dompurify": "^3.4.1", + "highlight.js": "^11.11.1", "lucide-solid": "^0.545.0", "marked": "^17.0.1", "qrcode": "^1.5.4", diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 51dbae923..c41902f06 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -19,6 +19,7 @@ import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw'; import SparklesIcon from 'lucide-solid/icons/sparkles'; import XIcon from 'lucide-solid/icons/x'; import { renderMarkdown } from '../aiChatUtils'; +import { highlightSettledCodeBlocks } from './aiCodeHighlight'; import { morphMarkdownInto } from './markdownMorph'; import { PendingToolBlock, ToolCancellationBlock, ToolExecutionBlock } from './ToolExecutionBlock'; import { ApprovalCard } from './ApprovalCard'; @@ -223,6 +224,13 @@ const AssistantMarkdownBlock: Component<{ createEffect(() => { const html = renderMarkdown(visibleText()); if (container) morphMarkdownInto(container, html); + // Highlight only once the turn settles: re-highlighting per streaming + // delta would fight the morph (it diffs against plain markup) and churn + // the DOM. The highlighter runs over the sanitized DOM, so its spans + // never pass through DOMPurify. + if (container && props.streaming !== true) { + void highlightSettledCodeBlocks(container); + } }); return
; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/aiCodeHighlight.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/aiCodeHighlight.test.ts new file mode 100644 index 000000000..2cacd2bdc --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/aiCodeHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { highlightSettledCodeBlocks } from '../aiCodeHighlight'; + +const makeContainer = (html: string): HTMLElement => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + return container; +}; + +describe('aiCodeHighlight', () => { + it('highlights a fenced bash block using the language hint', async () => { + const container = makeContainer( + '
echo "hello" # comment
', + ); + await highlightSettledCodeBlocks(container); + + const code = container.querySelector('code') as HTMLElement; + expect(code.dataset.highlighted).toBe('true'); + expect(code.classList.contains('hljs-highlighted')).toBe(true); + expect(code.querySelector('.hljs-string')).not.toBeNull(); + expect(code.querySelector('.hljs-comment')).not.toBeNull(); + // Highlighting must not alter the visible text. + expect(code.textContent).toBe('echo "hello" # comment'); + }); + + it('is idempotent: a highlighted block is not reprocessed', async () => { + const container = makeContainer('
{"a": 1}
'); + await highlightSettledCodeBlocks(container); + const firstPass = (container.querySelector('code') as HTMLElement).innerHTML; + await highlightSettledCodeBlocks(container); + expect((container.querySelector('code') as HTMLElement).innerHTML).toBe(firstPass); + }); + + it('falls back to auto-detection when the fence has no usable hint', async () => { + const container = makeContainer( + '
{"name": "pulse", "ok": true, "count": 3}
', + ); + await highlightSettledCodeBlocks(container); + const code = container.querySelector('code') as HTMLElement; + expect(code.dataset.highlighted).toBe('true'); + expect(code.textContent).toBe('{"name": "pulse", "ok": true, "count": 3}'); + }); + + it('leaves empty blocks alone', async () => { + const container = makeContainer('
   
'); + await highlightSettledCodeBlocks(container); + const code = container.querySelector('code') as HTMLElement; + expect(code.dataset.highlighted).toBeUndefined(); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts b/frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts new file mode 100644 index 000000000..7a264953e --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts @@ -0,0 +1,87 @@ +import { logger } from '@/utils/logger'; +import type { HLJSApi } from 'highlight.js'; + +// Lazy-loaded so highlight.js bills to an async chunk, never the entry +// bundle. Grammar set is the infra vocabulary Assistant answers actually +// contain; everything else renders unhighlighted rather than pulling in the +// full grammar pack. +let hljsPromise: Promise | undefined; + +const loadHighlighter = (): Promise => { + if (!hljsPromise) { + hljsPromise = (async () => { + try { + const [core, bash, json, yaml, ini, sql, dockerfile, nginx, plaintext] = + await Promise.all([ + import('highlight.js/lib/core'), + import('highlight.js/lib/languages/bash'), + import('highlight.js/lib/languages/json'), + import('highlight.js/lib/languages/yaml'), + import('highlight.js/lib/languages/ini'), + import('highlight.js/lib/languages/sql'), + import('highlight.js/lib/languages/dockerfile'), + import('highlight.js/lib/languages/nginx'), + import('highlight.js/lib/languages/plaintext'), + ]); + const hljs = core.default; + hljs.registerLanguage('bash', bash.default); + hljs.registerAliases(['sh', 'shell', 'zsh', 'console'], { languageName: 'bash' }); + hljs.registerLanguage('json', json.default); + hljs.registerLanguage('yaml', yaml.default); + hljs.registerAliases(['yml'], { languageName: 'yaml' }); + hljs.registerLanguage('ini', ini.default); + hljs.registerAliases(['toml', 'conf'], { languageName: 'ini' }); + hljs.registerLanguage('sql', sql.default); + hljs.registerLanguage('dockerfile', dockerfile.default); + hljs.registerLanguage('nginx', nginx.default); + hljs.registerLanguage('plaintext', plaintext.default); + return hljs; + } catch (error) { + logger.debug('[AICodeHighlight] Failed to load highlighter', { error }); + return null; + } + })(); + } + return hljsPromise; +}; + +const fenceLanguage = (code: Element): string => { + for (const cls of Array.from(code.classList)) { + if (cls.startsWith('language-')) return cls.slice('language-'.length).toLowerCase(); + } + return ''; +}; + +// Highlights fenced code blocks inside a settled (non-streaming) markdown +// container. Runs over the sanitized DOM, so highlight spans never pass +// through DOMPurify and the 'class' allowlist stays closed to the model. +// Idempotent per block via data-highlighted. +export const highlightSettledCodeBlocks = async (container: HTMLElement): Promise => { + const blocks = Array.from(container.querySelectorAll('pre code')).filter( + (code) => !(code as HTMLElement).dataset.highlighted, + ); + if (blocks.length === 0) return; + + const hljs = await loadHighlighter(); + if (!hljs) return; + + for (const code of blocks) { + const element = code as HTMLElement; + if (element.dataset.highlighted) continue; + const text = element.textContent || ''; + if (!text.trim()) continue; + const language = fenceLanguage(element); + try { + const result = + language && hljs.getLanguage(language) + ? hljs.highlight(text, { language, ignoreIllegals: true }) + : hljs.highlightAuto(text, ['bash', 'json', 'yaml', 'ini']); + element.innerHTML = result.value; + element.dataset.highlighted = 'true'; + element.classList.add('hljs-highlighted'); + } catch (error) { + logger.debug('[AICodeHighlight] Failed to highlight block', { error }); + element.dataset.highlighted = 'true'; + } + } +}; diff --git a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts index 3da5d7dd8..e644cba8d 100644 --- a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts +++ b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts @@ -150,6 +150,23 @@ describe('aiChatUtils', () => { expect(output).not.toContain('inset-0'); }); + // The one class carve-out: marked's fence-language hint on , so + // the lazy syntax highlighter can pick a grammar. Pattern-pinned. + it('keeps the language-x class on fenced code blocks only', () => { + const output = utils.renderMarkdown(['```bash', 'df -h', '```'].join('\n')); + expect(output).toContain('language-bash'); + + const hostile = utils.renderMarkdown( + 'xy', + ); + expect(hostile).not.toContain('fixed'); + // Multi-class values fail the ^language-x$ pattern and are dropped whole. + expect(hostile).not.toContain('language-bash extra'); + + const nonCode = utils.renderMarkdown('
z
'); + expect(nonCode).not.toContain('class='); + }); + // Regression: real http/https links still render and pick up the safe // target/rel attributes from the afterSanitizeAttributes hook. it('preserves https links and applies target/rel', () => { diff --git a/frontend-modern/src/components/AI/aiChatUtils.ts b/frontend-modern/src/components/AI/aiChatUtils.ts index b214c7c72..7bf9de19c 100644 --- a/frontend-modern/src/components/AI/aiChatUtils.ts +++ b/frontend-modern/src/components/AI/aiChatUtils.ts @@ -45,6 +45,19 @@ const configureDOMPurify = () => { element.setAttribute('target', '_blank'); element.setAttribute('rel', 'noopener noreferrer'); }); + + // 'class' stays out of ALLOWED_ATTR (UI-redressing surface — see the + // hardening notes in renderMarkdown). The single carve-out is marked's + // fence-language hint on , pattern-pinned so only `language-x` + // survives; the lazy syntax highlighter needs it to pick a grammar. + DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { + const element = node as Element | null; + if (!element || element.tagName !== 'CODE') return; + if (data.attrName !== 'class') return; + if (/^language-[a-z0-9+#_-]{1,30}$/i.test(data.attrValue)) { + data.forceKeepAttr = true; + } + }); }; const coerceMarkdownInput = (content: unknown): string => { diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index a597d0c42..4f2194d32 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -140,6 +140,47 @@ } @layer components { + /* Assistant code-block syntax highlighting (highlight.js tokens). + Chat code blocks always render on the dark prose-pre slate background, + so a single readable-on-dark palette serves both app themes. */ + .prose pre code.hljs-highlighted .hljs-comment, + .prose pre code.hljs-highlighted .hljs-quote { + color: theme('colors.slate.400'); + font-style: italic; + } + .prose pre code.hljs-highlighted .hljs-keyword, + .prose pre code.hljs-highlighted .hljs-selector-tag, + .prose pre code.hljs-highlighted .hljs-literal, + .prose pre code.hljs-highlighted .hljs-built_in { + color: theme('colors.violet.300'); + } + .prose pre code.hljs-highlighted .hljs-string, + .prose pre code.hljs-highlighted .hljs-regexp, + .prose pre code.hljs-highlighted .hljs-addition { + color: theme('colors.emerald.300'); + } + .prose pre code.hljs-highlighted .hljs-number, + .prose pre code.hljs-highlighted .hljs-symbol, + .prose pre code.hljs-highlighted .hljs-bullet { + color: theme('colors.amber.300'); + } + .prose pre code.hljs-highlighted .hljs-title, + .prose pre code.hljs-highlighted .hljs-section, + .prose pre code.hljs-highlighted .hljs-name, + .prose pre code.hljs-highlighted .hljs-function { + color: theme('colors.sky.300'); + } + .prose pre code.hljs-highlighted .hljs-attr, + .prose pre code.hljs-highlighted .hljs-attribute, + .prose pre code.hljs-highlighted .hljs-variable, + .prose pre code.hljs-highlighted .hljs-template-variable { + color: theme('colors.cyan.300'); + } + .prose pre code.hljs-highlighted .hljs-meta, + .prose pre code.hljs-highlighted .hljs-deletion { + color: theme('colors.rose.300'); + } + .touch-scroll { -webkit-overflow-scrolling: touch; } diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index 2ab1d1b16..b11d72aa2 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -254,6 +254,10 @@ export default defineConfig({ if (id.includes('solid-js') || id.includes('@solidjs/router')) return 'vendor-solid'; if (id.includes('lucide-solid')) return 'vendor-icons'; if (id.includes('marked') || id.includes('dompurify')) return 'vendor-ai'; + // Only ever dynamically imported (Assistant code-block + // highlighting on settled turns); folding it into the eager + // vendor chunk would bill it to first load. + if (id.includes('highlight.js')) return 'vendor-highlight'; return 'vendor'; }