mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
commit
6b62b6c633
@ -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