ui: highlight WebChat code blocks (#83569)

Summary:
- The PR adds highlight.js-backed WebChat code-block highlighting, scoped token CSS, regression tests, a type shim, and a direct UI dependency.
- Reproducibility: not applicable. as a bug reproduction; this is a feature addition. The feature gap is source-evident because current main renders code blocks as escaped plaintext without hljs token markup.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 7bb95c47ed.
- Required merge gates passed before the squash merge.

Prepared head SHA: 7bb95c47ed
Review: https://github.com/openclaw/openclaw/pull/83569#issuecomment-4476990135

Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
zhengzuo0-ai 2026-05-19 00:53:24 +08:00 committed by GitHub
parent 46c622aa3b
commit 1fbb4e4e6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 364 additions and 15 deletions

3
pnpm-lock.yaml generated
View file

@ -1736,6 +1736,9 @@ importers:
dompurify:
specifier: 3.4.3
version: 3.4.3
highlight.js:
specifier: 10.7.3
version: 10.7.3
json5:
specifier: 2.2.3
version: 2.2.3

View file

@ -12,6 +12,7 @@
"@create-markdown/preview": "2.0.3",
"@noble/ed25519": "3.1.0",
"dompurify": "3.4.3",
"highlight.js": "10.7.3",
"json5": "2.2.3",
"lit": "3.3.3",
"markdown-it": "14.1.1",

View file

@ -2004,6 +2004,159 @@
background: var(--bg);
}
:is(.code-block .hljs, .code-block-wrapper pre code.hljs) {
color: var(--text);
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-comment,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-quote {
color: var(--muted);
font-style: italic;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-tag,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst {
color: #ff8a80;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-template-variable,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-tag .hljs-attr {
color: #79c0ff;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag {
color: #a5d6ff;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-id,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-function .hljs-title {
color: #d2a8ff;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-class .hljs-title,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-builtin-name {
color: #ffa657;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-class,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-attr,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-selector-pseudo {
color: #7ee787;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-symbol,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-bullet,
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-link {
color: var(--accent-2);
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta {
color: #c9d1d9;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-deletion {
color: #ffa198;
background: rgba(248, 81, 73, 0.12);
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-addition {
color: #7ee787;
background: rgba(46, 160, 67, 0.12);
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-emphasis {
font-style: italic;
}
:is(.code-block, .code-block-wrapper pre code.hljs) .hljs-strong {
font-weight: 600;
}
:root[data-theme-mode="light"] :is(.code-block .hljs, .code-block-wrapper pre code.hljs) {
color: var(--text);
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-selector-tag,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-subst {
color: #cf222e;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-number,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-literal,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-variable,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-template-variable,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-tag
.hljs-attr {
color: #0550ae;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-doctag {
color: #0a3069;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-title,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-section,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-selector-id,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-function
.hljs-title {
color: #8250df;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-type,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-class
.hljs-title,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-built_in,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-builtin-name {
color: #953800;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attr,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-attribute,
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-name,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-selector-class,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-selector-attr,
:root[data-theme-mode="light"]
:is(.code-block, .code-block-wrapper pre code.hljs)
.hljs-selector-pseudo {
color: #116329;
}
:root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-meta {
color: #57606a;
}
.markdown-plain-text-fallback {
display: block;
white-space: pre-wrap;

View file

@ -5,6 +5,18 @@ function readComponentsCss(): string {
return readStyleSheet("ui/src/styles/components.css");
}
describe("code block highlight styles", () => {
it("targets the markdown renderer's generated code block wrapper", () => {
const css = readComponentsCss();
expect(css).toContain(":is(.code-block .hljs, .code-block-wrapper pre code.hljs)");
expect(css).toContain(":is(.code-block, .code-block-wrapper pre code.hljs) .hljs-keyword");
expect(css).toContain(
':root[data-theme-mode="light"] :is(.code-block, .code-block-wrapper pre code.hljs) .hljs-string',
);
});
});
describe("agent fallback chip styles", () => {
it("styles the chip remove control inside the agent model input", () => {
const css = readComponentsCss();

View file

@ -0,0 +1,9 @@
declare module "highlight.js/lib/core.js" {
import hljs = require("highlight.js");
export default hljs;
}
declare module "highlight.js/lib/languages/*.js" {
export default function language(hljs?: HLJSApi): LanguageDetail;
}

View file

@ -332,9 +332,14 @@ describe("toSanitizedMarkdownHtml", () => {
describe("code blocks", () => {
it("renders fenced code blocks", () => {
const html = toSanitizedMarkdownHtml("```ts\nconsole.log(1)\n```");
expect(html).toBe(
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="console.log(1)" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-ts">console.log(1)\n</code></pre></div>',
);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code");
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("ts");
expect(copy?.dataset.code).toBe("console.log(1)");
expect(code?.classList.contains("language-ts")).toBe(true);
expect(code?.textContent).toBe("console.log(1)\n");
});
it("renders indented code blocks", () => {
@ -352,6 +357,52 @@ describe("toSanitizedMarkdownHtml", () => {
);
});
it("highlights fenced code blocks while preserving copy text", () => {
const source = 'const answer = "yes";\nconsole.log(answer);\n';
const html = toSanitizedMarkdownHtml(`\`\`\`js\n${source}\`\`\``);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code");
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("js");
expect(copy?.dataset.code).toBe(source.trimEnd());
expect(code?.textContent).toBe(source);
expect(code?.querySelector(".hljs-keyword")?.textContent).toBe("const");
expect(code?.querySelector(".hljs-string")?.textContent).toBe('"yes"');
});
it("highlights collapsed JSON code blocks", () => {
const html = toSanitizedMarkdownHtml('```json\n{"ok": true}\n```');
const fragment = htmlFragment(html);
const details = fragment.querySelector("details.json-collapse");
const code = details?.querySelector("pre code");
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
expect(code?.textContent).toBe('{"ok": true}\n');
expect(code?.innerHTML).toContain("hljs-");
});
it("auto-highlights unlabeled code blocks only when detection is confident", () => {
const html = toSanitizedMarkdownHtml("```\n#include <vector>\nstd::vector<int> nums;\n```");
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code");
expect(code?.classList.contains("hljs")).toBe(true);
expect(code?.textContent).toBe("#include <vector>\nstd::vector<int> nums;\n");
expect(code?.innerHTML).toContain("hljs-meta");
expect(code?.innerHTML).toContain("hljs-keyword");
});
it("keeps highlighted HTML code escaped", () => {
const html = toSanitizedMarkdownHtml("```html\n<script>alert(1)</script>\n```");
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code");
expect(code?.querySelector("script")).toBeNull();
expect(code?.textContent).toBe("<script>alert(1)</script>\n");
expect(code?.innerHTML).not.toContain("<script>");
});
it("keeps localized copy labels fresh after locale changes", async () => {
const markdown = "```ts\nconst localizedCopy = true;\n```";
await i18n.setLocale("en");
@ -360,12 +411,25 @@ describe("toSanitizedMarkdownHtml", () => {
try {
await i18n.setLocale("zh-CN");
const chinese = toSanitizedMarkdownHtml(markdown);
const englishFragment = htmlFragment(english);
const chineseFragment = htmlFragment(chinese);
const englishCopy = englishFragment.querySelector<HTMLButtonElement>(".code-block-copy");
const chineseCopy = chineseFragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(english).toBe(
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="const localizedCopy = true;" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-ts">const localizedCopy = true;\n</code></pre></div>',
expect(englishCopy?.dataset.code).toBe("const localizedCopy = true;");
expect(englishCopy?.getAttribute("aria-label")).toBe("Copy code");
expect(englishCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("Copy");
expect(englishCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("Copied!");
expect(englishFragment.querySelector("pre code")?.textContent).toBe(
"const localizedCopy = true;\n",
);
expect(chinese).toBe(
'<div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">ts</span><button type="button" class="code-block-copy" data-code="const localizedCopy = true;" aria-label="复制代码"><span class="code-block-copy__idle">复制</span><span class="code-block-copy__done">已复制!</span></button></div><pre><code class="language-ts">const localizedCopy = true;\n</code></pre></div>',
expect(chineseCopy?.dataset.code).toBe("const localizedCopy = true;");
expect(chineseCopy?.getAttribute("aria-label")).toBe("复制代码");
expect(chineseCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("复制");
expect(chineseCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("已复制!");
expect(chineseFragment.querySelector("pre code")?.textContent).toBe(
"const localizedCopy = true;\n",
);
} finally {
await i18n.setLocale("en");
@ -374,9 +438,16 @@ describe("toSanitizedMarkdownHtml", () => {
it("collapses JSON code blocks", () => {
const html = toSanitizedMarkdownHtml('```json\n{"key": "value"}\n```');
expect(html).toBe(
'<details class="json-collapse"><summary>JSON · 2 lines</summary><div class="code-block-wrapper"><div class="code-block-header"><span class="code-block-lang">json</span><button type="button" class="code-block-copy" data-code="{&quot;key&quot;: &quot;value&quot;}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code class="language-json">{"key": "value"}\n</code></pre></div></details>',
);
const fragment = htmlFragment(html);
const details = fragment.querySelector("details.json-collapse");
const code = details?.querySelector("pre code");
const copy = details?.querySelector<HTMLButtonElement>(".code-block-copy");
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
expect(details?.querySelector(".code-block-lang")?.textContent).toBe("json");
expect(copy?.dataset.code).toBe('{"key": "value"}');
expect(code?.classList.contains("language-json")).toBe(true);
expect(code?.textContent).toBe('{"key": "value"}\n');
});
});

View file

@ -1,4 +1,19 @@
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core.js";
import bash from "highlight.js/lib/languages/bash.js";
import cpp from "highlight.js/lib/languages/cpp.js";
import css from "highlight.js/lib/languages/css.js";
import diff from "highlight.js/lib/languages/diff.js";
import go from "highlight.js/lib/languages/go.js";
import java from "highlight.js/lib/languages/java.js";
import javascript from "highlight.js/lib/languages/javascript.js";
import json from "highlight.js/lib/languages/json.js";
import markdown from "highlight.js/lib/languages/markdown.js";
import python from "highlight.js/lib/languages/python.js";
import rust from "highlight.js/lib/languages/rust.js";
import typescript from "highlight.js/lib/languages/typescript.js";
import xml from "highlight.js/lib/languages/xml.js";
import yaml from "highlight.js/lib/languages/yaml.js";
import MarkdownIt from "markdown-it";
import markdownItTaskLists from "markdown-it-task-lists";
import { i18n, t } from "../i18n/index.ts";
@ -151,6 +166,90 @@ function normalizeMarkdownImageLabel(text?: string | null): string {
return trimmed ? trimmed : "image";
}
for (const [language, definition, aliases] of [
["bash", bash, ["sh", "shell"]],
["cpp", cpp, ["c++", "cxx"]],
["css", css, []],
["diff", diff, ["patch"]],
["go", go, ["golang"]],
["java", java, []],
["javascript", javascript, ["js", "jsx"]],
["json", json, []],
["markdown", markdown, ["md"]],
["python", python, ["py"]],
["rust", rust, ["rs"]],
["typescript", typescript, ["ts", "tsx"]],
["xml", xml, ["html", "svg"]],
["yaml", yaml, ["yml"]],
] as const) {
hljs.registerLanguage(language, definition);
if (aliases.length > 0) {
hljs.registerAliases([...aliases], { languageName: language });
}
}
function normalizeHighlightLanguage(lang: string): string {
const normalized = lang.trim().toLowerCase();
if (!normalized) {
return "";
}
const aliases: Record<string, string> = {
"c++": "cpp",
cxx: "cpp",
js: "javascript",
jsx: "javascript",
md: "markdown",
sh: "bash",
shell: "bash",
ts: "typescript",
tsx: "typescript",
};
return aliases[normalized] ?? normalized;
}
const autoHighlightLanguages = [
"bash",
"cpp",
"css",
"diff",
"go",
"java",
"javascript",
"json",
"markdown",
"python",
"rust",
"typescript",
"xml",
"yaml",
];
function highlightCode(text: string, lang: string): string {
const language = normalizeHighlightLanguage(lang);
try {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(text, { language, ignoreIllegals: true }).value;
}
if (!language && text.trim()) {
const result = hljs.highlightAuto(text, autoHighlightLanguages);
if (result.relevance >= 2) {
return result.value;
}
}
} catch {
// Fall back to escaped plaintext; malformed input should not break chat rendering.
}
return escapeHtml(text);
}
function codeClassAttribute(lang: string, highlighted: string): string {
const classes = [
highlighted.includes("hljs-") ? "hljs" : "",
lang ? `language-${lang}` : "",
].filter(Boolean);
return classes.length > 0 ? ` class="${escapeHtml(classes.join(" "))}"` : "";
}
export const md = new MarkdownIt({
html: true, // Enable HTML recognition so html_block/html_inline overrides can escape it
breaks: true,
@ -427,9 +526,9 @@ md.renderer.rules.fence = (tokens, idx) => {
// extract only the first whitespace-separated token as the language.
const lang = token.info.trim().split(/\s+/)[0] || "";
const text = token.content;
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
const safeText = escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
const highlighted = highlightCode(text, lang);
const classAttr = codeClassAttribute(lang, highlighted);
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
const attrSafe = escapeHtml(text);
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
@ -455,8 +554,9 @@ md.renderer.rules.fence = (tokens, idx) => {
md.renderer.rules.code_block = (tokens, idx) => {
const token = tokens[idx];
const text = token.content;
const safeText = escapeHtml(text);
const codeBlock = `<pre><code>${safeText}</code></pre>`;
const highlighted = highlightCode(text, "");
const classAttr = codeClassAttribute("", highlighted);
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
const attrSafe = escapeHtml(text);
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
const header = `<div class="code-block-header">${copyBtn}</div>`;