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'; }