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