mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
a159ee3511
commit
4fce6014a6
@ -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=="],
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user