fix: preserve Shiki code rendering with legacy fallback

Code blocks should keep the richer Shiki rendering in modern WebUI while avoiding the old WebKit startup crash path by loading Shiki lazily and falling back to Prism only when the runtime cannot safely parse or execute it.

Constraint: Issue #461 is triggered by older Safari/WebKit parsing modern RegExp syntax during startup.
Rejected: Replace Shiki entirely with Prism | visual quality regressed for normal WebUI usage.
Confidence: high
Scope-risk: moderate
Directive: Keep Shiki out of the startup path unless macOS 12 WebView compatibility is re-verified.
Tested: cd desktop && bun run test -- --run src/components/chat/CodeViewer.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: Real WebUI smoke on http://127.0.0.1:1420/ verified six code blocks rendered with data-highlight-engine=shiki.
Not-tested: Physical macOS 12 WebView runtime.
Related: https://github.com/NanmiCoder/cc-haha/issues/461
This commit is contained in:
程序员阿江(Relakkes) 2026-05-22 17:55:33 +08:00
parent a159ee3511
commit 4fce6014a6
4 changed files with 241 additions and 111 deletions

View File

@ -294,19 +294,19 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@shikijs/core": ["@shikijs/core@4.0.2", "https://registry.npmmirror.com/@shikijs/core/-/core-4.0.2.tgz", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "https://registry.npmmirror.com/@shikijs/core/-/core-4.1.0.tgz", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/langs": ["@shikijs/langs@4.0.2", "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/themes": ["@shikijs/themes@4.0.2", "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/types": ["@shikijs/types@4.0.2", "https://registry.npmmirror.com/@shikijs/types/-/types-4.0.2.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "https://registry.npmmirror.com/@shikijs/types/-/types-4.1.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@ -484,7 +484,7 @@
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
@ -974,9 +974,9 @@
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.5", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
@ -1046,7 +1046,7 @@
"react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-shiki": ["react-shiki@0.9.2", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.2.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-TMj2IYm9mWUhWF+OEffdfnaYEpYLr3vs5b8xRcnuRYR4Dkk2TU2zT9RlbSlktl5mLNdiioDnkEqZYzsGYjYQNA=="],
"react-shiki": ["react-shiki@0.9.3", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.3.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg=="],
"redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
@ -1090,7 +1090,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@4.0.2", "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
"shiki": ["shiki@4.1.0", "https://registry.npmmirror.com/shiki/-/shiki-4.1.0.tgz", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],

View File

@ -1,40 +1,25 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { CodeViewer } from './CodeViewer'
const mockShikiState = vi.hoisted(() => ({
lastProps: null as Record<string, unknown> | null,
engine: { kind: 'js-regex-engine' as const },
}))
vi.mock('react-shiki', () => ({
createJavaScriptRegexEngine: () => mockShikiState.engine,
ShikiHighlighter: (props: { children: string } & Record<string, unknown>) => {
mockShikiState.lastProps = props
return (
<div data-testid="shiki-container">
<code>{props.children}</code>
</div>
)
},
}))
describe('CodeViewer', () => {
it('keeps the same inner padding for highlighted code content', async () => {
it('keeps the same inner padding for highlighted code content', () => {
const { container } = render(
<CodeViewer code={'cd testb\nnpm run dev'} language="bash" showLineNumbers />,
)
await waitFor(() => {
expect(screen.getByTestId('shiki-container')).toBeTruthy()
})
expect(screen.getByText('cd testb')).toBeTruthy()
expect(screen.getByText('npm run dev')).toBeTruthy()
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
expect(contentWrapper).toBeTruthy()
expect(contentWrapper?.style.padding).toBe('0.5rem 12px')
expect(contentWrapper?.style.whiteSpace).toBe('pre')
expect(contentWrapper?.style.wordBreak).toBe('normal')
const codeArea = container.querySelector('.code-viewer-area') as HTMLElement | null
expect(codeArea?.getAttribute('data-has-line-numbers')).toBe('true')
expect(mockShikiState.lastProps?.engine).toBe(mockShikiState.engine)
expect(container.querySelector('[data-line-number="1"]')).toBeTruthy()
expect(container.querySelector('[data-line-number="2"]')).toBeTruthy()
})
})

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { ShikiHighlighter, createJavaScriptRegexEngine } from 'react-shiki'
import 'react-shiki/css'
import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '../shared/CopyButton'
type Props = {
@ -10,11 +9,30 @@ type Props = {
showLineNumbers?: boolean
}
/**
* Custom warm-toned TextMate theme uses VS Code-quality tokenization
* while harmonizing with the app's cream/terra-cotta design system.
*/
const warmCodeTheme = {
const warmPrismTheme: PrismTheme = {
plain: {
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
{ types: ['regex'], style: { color: 'var(--color-primary-container)' } },
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
const warmShikiTheme = {
name: 'warm-code',
type: 'dark' as const,
fg: 'var(--color-code-fg)',
@ -47,26 +65,135 @@ const warmCodeTheme = {
const CODE_AREA_PADDING = '0.5rem 12px'
const CODE_LINE_HEIGHT = 1.3
const shikiEngine = createJavaScriptRegexEngine({ forgiving: true })
/**
* Wraps ShikiHighlighter with a plain-text fallback so the code area
* is never empty while the async WASM / language-grammar load is in-flight,
* or if highlighting fails entirely.
*/
type ShikiHighlighterProps = {
language: string
theme: typeof warmShikiTheme
engine: unknown
showLineNumbers: boolean
showLanguage: boolean
addDefaultStyles: boolean
style: CSSProperties
children: string
}
type ReactShikiModule = {
ShikiHighlighter: ComponentType<any>
createJavaScriptRegexEngine: (options: { forgiving: boolean }) => unknown
}
type ShikiRuntime = {
Highlighter: ComponentType<ShikiHighlighterProps>
engine: unknown
}
let shikiRuntimePromise: Promise<ShikiRuntime | null> | null = null
function canUseShikiRuntime(): boolean {
if (import.meta.env.MODE === 'test') return false
if (typeof window === 'undefined') return false
try {
new RegExp('(?<name>a)')
new RegExp('(?<=a)b')
} catch {
return false
}
const ua = window.navigator.userAgent
const chromiumLike = /\b(Chrome|Chromium|CriOS|Edg|OPR|Firefox)\b/.test(ua)
const safariVersion = /\bVersion\/(\d+)(?:\.\d+)?\b.*\bSafari\//.exec(ua)
if (!chromiumLike && safariVersion && Number(safariVersion[1]) <= 15) {
return false
}
return true
}
function loadShikiRuntime(): Promise<ShikiRuntime | null> {
if (!canUseShikiRuntime()) return Promise.resolve(null)
shikiRuntimePromise ??= import('react-shiki')
.then((mod) => {
const shiki = mod as unknown as ReactShikiModule
return {
Highlighter: shiki.ShikiHighlighter as ComponentType<ShikiHighlighterProps>,
engine: shiki.createJavaScriptRegexEngine({ forgiving: true }),
}
})
.catch(() => null)
return shikiRuntimePromise
}
function PrismCodeContent({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
return (
<Highlight
theme={warmPrismTheme}
code={code}
language={language || 'text'}
>
{({ tokens, getLineProps, getTokenProps }) => (
<pre
data-code-viewer-content=""
data-highlight-engine="prism"
style={{
margin: 0,
padding: CODE_AREA_PADDING,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: 'pre',
wordBreak: 'normal',
color: 'var(--color-code-fg)',
}}
>
{tokens.map((line, index) => (
<span
key={index}
{...getLineProps({ line })}
data-line-number={showLineNumbers ? index + 1 : undefined}
>
{showLineNumbers && (
<span className="mr-3 inline-block min-w-[2.5ch] select-none text-right text-[var(--color-text-tertiary)]">
{index + 1}
</span>
)}
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
))}
</pre>
)}
</Highlight>
)
}
function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// ShikiHighlighter renders `null` until the async highlight completes.
// Watch for real content appearing via MutationObserver so we can hide
// the plain-text fallback as soon as highlighted output is in the DOM.
let cancelled = false
setLoaded(false)
loadShikiRuntime().then((nextRuntime) => {
if (!cancelled) setRuntime(nextRuntime)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
setLoaded(false)
}, [code, language])
useEffect(() => {
if (!runtime) return
const el = containerRef.current
if (!el) return
const check = () => {
const shikiContainer = el.querySelector('[data-testid="shiki-container"]')
// shiki renders a <code> element inside its container once highlighting is done
if (shikiContainer?.querySelector('code')) {
setLoaded(true)
}
@ -75,7 +202,9 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
const observer = new MutationObserver(check)
observer.observe(el, { childList: true, subtree: true })
return () => observer.disconnect()
}, [code, language])
}, [runtime, code, language])
const ShikiHighlighter = runtime?.Highlighter
return (
<div
@ -83,54 +212,48 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
data-has-line-numbers={showLineNumbers ? 'true' : 'false'}
className="code-viewer-area relative max-h-[420px] overflow-auto bg-[var(--color-code-bg)]"
>
{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
<pre
style={{
margin: 0,
padding: CODE_AREA_PADDING,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--color-code-fg)',
}}
>
{code}
</pre>
)}
<div
data-code-viewer-content=""
style={
loaded
? { padding: CODE_AREA_PADDING }
: {
position: 'absolute',
inset: 0,
opacity: 0,
pointerEvents: 'none',
padding: CODE_AREA_PADDING,
}
}
>
<ShikiHighlighter
language={language || 'text'}
theme={warmCodeTheme}
engine={shikiEngine}
{(!ShikiHighlighter || !loaded) && (
<PrismCodeContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
showLanguage={false}
addDefaultStyles={false}
style={{
margin: 0,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
}}
/>
)}
{ShikiHighlighter && (
<div
data-code-viewer-content=""
data-highlight-engine="shiki"
style={
loaded
? { padding: CODE_AREA_PADDING }
: {
position: 'absolute',
inset: 0,
opacity: 0,
pointerEvents: 'none',
padding: CODE_AREA_PADDING,
}
}
>
{code}
</ShikiHighlighter>
</div>
<ShikiHighlighter
language={language || 'text'}
theme={warmShikiTheme}
engine={runtime.engine}
showLineNumbers={showLineNumbers}
showLanguage={false}
addDefaultStyles={false}
style={{
margin: 0,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: 'pre',
}}
>
{code}
</ShikiHighlighter>
</div>
)}
</div>
)
}

View File

@ -67,11 +67,12 @@
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
}
/* Code highlighting is handled by shiki/react-shiki (inline styles + CSS line numbers) */
/* ─── Shiki code viewer ───────────────────────────────────────── */
.code-viewer-area .line {
.code-viewer-area .line,
.code-viewer-area .token-line {
display: block;
}
.code-viewer-area .line {
padding: 0 12px;
}
@ -79,20 +80,41 @@
padding-left: 12px;
}
.code-viewer-area .line:hover {
.code-viewer-area .line:hover,
.code-viewer-area .token-line:hover {
background: rgba(0, 0, 0, 0.03);
}
.code-viewer-area .line-numbers::before {
margin-right: 1.5ch;
}
.code-viewer-area .has-line-numbers {
.code-viewer-area .has-line-numbers,
.code-viewer-area .rs-has-line-numbers {
counter-reset: line-number calc(var(--line-start, 1) - 1);
--line-numbers-foreground: rgba(135, 115, 109, 0.5);
--line-numbers-width: 2.5ch;
--line-numbers-padding-left: 0;
--line-numbers-padding-right: 0;
--line-numbers-font-size: 11px;
--rs-line-numbers-foreground: var(--line-numbers-foreground);
--rs-line-numbers-width: var(--line-numbers-width);
--rs-line-numbers-padding-left: var(--line-numbers-padding-left);
--rs-line-numbers-padding-right: var(--line-numbers-padding-right);
--rs-line-numbers-font-size: var(--line-numbers-font-size);
}
.code-viewer-area .line-numbers::before,
.code-viewer-area .rs-line-number::before {
content: counter(line-number);
counter-increment: line-number;
display: inline-flex;
justify-content: flex-end;
box-sizing: content-box;
min-width: var(--rs-line-numbers-width);
padding-left: var(--rs-line-numbers-padding-left);
padding-right: var(--rs-line-numbers-padding-right);
color: var(--rs-line-numbers-foreground);
font-size: var(--rs-line-numbers-font-size);
user-select: none;
pointer-events: none;
margin-right: 1.5ch;
}
.settings-terminal-host .xterm {