mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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:
parent
db7679a984
commit
cc684ab689
9 changed files with 232 additions and 0 deletions
10
frontend-modern/package-lock.json
generated
10
frontend-modern/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
87
frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts
Normal file
87
frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue