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 <code> 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.
This commit is contained in:
rcourtman 2026-06-11 20:57:13 +01:00
parent db7679a984
commit cc684ab689
9 changed files with 232 additions and 0 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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 <div ref={container} class={markdownClass} aria-live={props.streaming ? 'polite' : undefined} />;

View file

@ -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(
'<pre><code class="language-bash">echo "hello" # comment</code></pre>',
);
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('<pre><code class="language-json">{"a": 1}</code></pre>');
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(
'<pre><code>{"name": "pulse", "ok": true, "count": 3}</code></pre>',
);
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('<pre><code class="language-bash"> </code></pre>');
await highlightSettledCodeBlocks(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBeUndefined();
});
});

View file

@ -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<HLJSApi | null> | undefined;
const loadHighlighter = (): Promise<HLJSApi | null> => {
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<void> => {
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';
}
}
};

View file

@ -150,6 +150,23 @@ describe('aiChatUtils', () => {
expect(output).not.toContain('inset-0');
});
// The one class carve-out: marked's fence-language hint on <code>, 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(
'<code class="fixed inset-0 z-50">x</code><code class="language-bash extra">y</code>',
);
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('<div class="language-bash">z</div>');
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', () => {

View file

@ -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 <code>, 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 => {

View file

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

View file

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