Merge code rendering compatibility fix into local main

Bring the detached worktree fix back to the local main branch so modern WebUI keeps Shiki rendering while legacy WebKit can fall back before loading Shiki.

Constraint: Local main already contains unreleased commits ahead of origin/main.
Confidence: high
Scope-risk: moderate
Tested: Worktree commit passed focused CodeViewer test, desktop lint, desktop build, startup bundle scan, and real WebUI Shiki smoke before merge.
Not-tested: Re-running the full desktop gate after merge.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-22 17:55:45 +08:00
commit 6b62b6c633
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 {