mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-20 09:17:51 +00:00
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 head7bb95c47ed. - Required merge gates passed before the squash merge. Prepared head SHA:7bb95c47edReview: 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:
parent
46c622aa3b
commit
1fbb4e4e6a
7 changed files with 364 additions and 15 deletions
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
9
ui/src/types/highlight-js-subpaths.d.ts
vendored
Normal file
9
ui/src/types/highlight-js-subpaths.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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="{"key": "value"}" 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue