Merge pull request #2529 from lc6464/feat/web-code-highlight

feat(web): add markdown syntax highlighting for chat and skills
This commit is contained in:
daming大铭
2026-04-15 18:50:40 +08:00
committed by GitHub
7 changed files with 154 additions and 7 deletions
+2
View File
@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"highlight.js": "^11.11.1",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1",
@@ -35,6 +36,7 @@
"react-i18next": "^17.0.2",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
+57
View File
@@ -35,6 +35,9 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.20
highlight.js:
specifier: ^11.11.1
version: 11.11.1
i18next:
specifier: ^26.0.3
version: 26.0.3(typescript@5.9.3)
@@ -62,6 +65,9 @@ importers:
react-textarea-autosize:
specifier: ^8.5.9
version: 8.5.9(@types/react@19.2.14)(react@19.2.5)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -2433,6 +2439,9 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -2448,6 +2457,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -2463,6 +2475,10 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.12.12:
resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==}
engines: {node: '>=16.9.0'}
@@ -2807,6 +2823,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3371,6 +3390,9 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -3686,6 +3708,9 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -6253,6 +6278,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6309,6 +6338,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -6329,6 +6365,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
highlight.js@11.11.1: {}
hono@4.12.12: {}
html-parse-stringify@3.0.1:
@@ -6574,6 +6612,12 @@ snapshots:
longest-streak@3.1.0: {}
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -7350,6 +7394,14 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.1.0
vfile: 6.0.3
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -7744,6 +7796,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react"
import { useHighlightTheme } from "./hooks/use-highlight-theme"
interface AppProvidersProps {
children: ReactNode
}
export function AppProviders({ children }: AppProvidersProps) {
useHighlightTheme()
return <>{children}</>
}
@@ -7,6 +7,7 @@ import {
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
@@ -171,10 +172,10 @@ export function DetailSheet({
</div>
{detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-950/90 prose-pre:shadow-sm prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:shadow-sm dark:prose-pre:bg-zinc-950/90 prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
@@ -2,6 +2,7 @@ import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
@@ -63,7 +64,7 @@ export function AssistantMessage({
>
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none [overflow-wrap:anywhere] break-words",
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 dark:prose-pre:bg-zinc-950 max-w-none [overflow-wrap:anywhere] break-words",
isThought
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
@@ -71,7 +72,7 @@ export function AssistantMessage({
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
>
{content}
</ReactMarkdown>
@@ -0,0 +1,70 @@
import { useEffect } from "react"
import githubDarkCss from "highlight.js/styles/github-dark.css?inline"
import githubLightCss from "highlight.js/styles/github.css?inline"
const THEME_STYLE_ID = "hljs-theme-style"
const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
const THEME_STYLE_OWNER_VALUE = "true"
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}`
function getOrCreateThemeStyleElement(): HTMLStyleElement {
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
MANAGED_THEME_STYLE_SELECTOR,
)
if (managedStyleElement) {
return managedStyleElement
}
const existingStyleElement =
document.querySelector<HTMLStyleElement>(ID_THEME_STYLE_SELECTOR)
if (existingStyleElement) {
existingStyleElement.setAttribute(
THEME_STYLE_OWNER_ATTR,
THEME_STYLE_OWNER_VALUE,
)
return existingStyleElement
}
const conflictingElement = document.getElementById(THEME_STYLE_ID)
const styleElement = document.createElement("style")
if (!conflictingElement) {
styleElement.id = THEME_STYLE_ID
}
// Leave conflicting non-style nodes untouched and track the injected style explicitly.
styleElement.setAttribute(THEME_STYLE_OWNER_ATTR, THEME_STYLE_OWNER_VALUE)
document.head.appendChild(styleElement)
return styleElement
}
export function useHighlightTheme() {
useEffect(() => {
const root = document.documentElement
const styleElement = getOrCreateThemeStyleElement()
const applyTheme = () => {
const nextThemeCss = root.classList.contains("dark")
? githubDarkCss
: githubLightCss
styleElement.textContent = nextThemeCss
}
applyTheme()
const observer = new MutationObserver(() => {
applyTheme()
})
observer.observe(root, {
attributes: true,
attributeFilter: ["class"],
})
return () => {
observer.disconnect()
}
}, [])
}
+6 -3
View File
@@ -3,6 +3,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
import { AppProviders } from "./app-providers"
import "./i18n"
import "./index.css"
import { routeTree } from "./routeTree.gen"
@@ -27,9 +28,11 @@ if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<AppProviders>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AppProviders>
</StrictMode>,
)
}