mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
refactor: reuse shiki highlighter utils (#6235)
* refactor: improve shiki highlighter utils and reuse it in ShikiStreamService * refactor: reuse shiki highlighter and markdown-it renderer * refactor: import shiki/markdown-it/core dynamically * chore: update shiki
This commit is contained in:
parent
d9661602b2
commit
a9a0ae87d3
@ -123,7 +123,7 @@
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/diff": "^7",
|
||||
@ -200,7 +200,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.2.2",
|
||||
"shiki": "^3.4.2",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@ -219,7 +219,6 @@
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
},
|
||||
|
||||
@ -3,6 +3,7 @@ import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
|
||||
import * as cmThemes from '@uiw/codemirror-themes-all'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@ -11,6 +12,8 @@ interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
highlightCode: (code: string, language: string) => Promise<string>
|
||||
shikiMarkdownIt: (code: string) => Promise<string>
|
||||
themeNames: string[]
|
||||
activeShikiTheme: string
|
||||
activeCmTheme: any
|
||||
@ -21,6 +24,8 @@ const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
|
||||
cleanupTokenizers: () => {},
|
||||
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
|
||||
highlightCode: async () => '',
|
||||
shikiMarkdownIt: async () => '',
|
||||
themeNames: ['auto'],
|
||||
activeShikiTheme: 'auto',
|
||||
activeCmTheme: null,
|
||||
@ -37,7 +42,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeEditor.enabled) {
|
||||
import('shiki').then(({ bundledThemes }) => {
|
||||
getShiki().then(({ bundledThemes }) => {
|
||||
setShikiThemes(bundledThemes)
|
||||
})
|
||||
}
|
||||
@ -118,11 +123,35 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
const highlightCode = useCallback(
|
||||
async (code: string, language: string) => {
|
||||
const highlighter = await getHighlighter()
|
||||
await loadLanguageIfNeeded(highlighter, language)
|
||||
await loadThemeIfNeeded(highlighter, activeShikiTheme)
|
||||
return highlighter.codeToHtml(code, { lang: language, theme: activeShikiTheme })
|
||||
},
|
||||
[activeShikiTheme]
|
||||
)
|
||||
|
||||
// 使用 Shiki 和 Markdown-it 渲染代码
|
||||
const shikiMarkdownIt = useCallback(
|
||||
async (code: string) => {
|
||||
const renderer = await getMarkdownIt(activeShikiTheme)
|
||||
if (!renderer) {
|
||||
return code
|
||||
}
|
||||
return renderer.render(code)
|
||||
},
|
||||
[activeShikiTheme]
|
||||
)
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
highlightCodeChunk,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
shikiMarkdownIt,
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
activeCmTheme,
|
||||
@ -132,6 +161,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
highlightCodeChunk,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
shikiMarkdownIt,
|
||||
themeNames,
|
||||
activeShikiTheme,
|
||||
activeCmTheme,
|
||||
|
||||
@ -1306,6 +1306,7 @@
|
||||
"dependenciesInstall": "Install Dependencies",
|
||||
"dependenciesInstalling": "Installing dependencies...",
|
||||
"description": "Description",
|
||||
"noDescriptionAvailable": "No description available",
|
||||
"duplicateName": "A server with this name already exists",
|
||||
"editJson": "Edit JSON",
|
||||
"editServer": "Edit Server",
|
||||
|
||||
@ -1303,6 +1303,7 @@
|
||||
"dependenciesInstall": "依存関係をインストール",
|
||||
"dependenciesInstalling": "依存関係をインストール中...",
|
||||
"description": "説明",
|
||||
"noDescriptionAvailable": "説明がありません",
|
||||
"duplicateName": "同じ名前のサーバーが既に存在します",
|
||||
"editJson": "JSONを編集",
|
||||
"editServer": "サーバーを編集",
|
||||
|
||||
@ -1303,6 +1303,7 @@
|
||||
"dependenciesInstall": "Установить зависимости",
|
||||
"dependenciesInstalling": "Установка зависимостей...",
|
||||
"description": "Описание",
|
||||
"noDescriptionAvailable": "Описание отсутствует",
|
||||
"duplicateName": "Сервер с таким именем уже существует",
|
||||
"editJson": "Редактировать JSON",
|
||||
"editServer": "Редактировать сервер",
|
||||
|
||||
@ -1306,6 +1306,7 @@
|
||||
"dependenciesInstall": "安装依赖项",
|
||||
"dependenciesInstalling": "正在安装依赖项...",
|
||||
"description": "描述",
|
||||
"noDescriptionAvailable": "暂无描述",
|
||||
"duplicateName": "已存在同名服务器",
|
||||
"editJson": "编辑JSON",
|
||||
"editServer": "编辑服务器",
|
||||
|
||||
@ -1306,6 +1306,7 @@
|
||||
"dependenciesInstall": "安裝相依套件",
|
||||
"dependenciesInstalling": "正在安裝相依套件...",
|
||||
"description": "描述",
|
||||
"noDescriptionAvailable": "描述不存在",
|
||||
"duplicateName": "已存在相同名稱的伺服器",
|
||||
"editJson": "編輯JSON",
|
||||
"editServer": "編輯伺服器",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { useShikiWithMarkdownIt } from '@renderer/utils/shiki'
|
||||
import { Collapse, message as antdMessage, Modal, Tabs, Tooltip } from 'antd'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { FC, memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -192,12 +192,7 @@ const MessageTools: FC<Props> = ({ blocks }) => {
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('message.tools.preview'),
|
||||
children: (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={resultString}
|
||||
/>
|
||||
)
|
||||
children: <CollapsedContent isExpanded={true} resultString={resultString} />
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
@ -215,9 +210,17 @@ const MessageTools: FC<Props> = ({ blocks }) => {
|
||||
|
||||
// New component to handle collapsed content
|
||||
const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => {
|
||||
const { renderedMarkdown: styledResult } = useShikiWithMarkdownIt(
|
||||
isExpanded ? `\`\`\`json\n${resultString}\n\`\`\`` : ''
|
||||
)
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [styledResult, setStyledResult] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const highlight = async () => {
|
||||
const result = await highlightCode(isExpanded ? resultString : '', 'json')
|
||||
setStyledResult(result)
|
||||
}
|
||||
|
||||
setTimeout(highlight, 0)
|
||||
}, [isExpanded, resultString, highlightCode])
|
||||
|
||||
if (!isExpanded) {
|
||||
return null
|
||||
@ -247,8 +250,11 @@ const CollapseContainer = styled(Collapse)`
|
||||
`
|
||||
|
||||
const MarkdownContainer = styled.div`
|
||||
& pre span {
|
||||
white-space: pre-wrap;
|
||||
& pre {
|
||||
background: transparent !important;
|
||||
span {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -371,4 +377,4 @@ const ExpandedResponseContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageTools
|
||||
export default memo(MessageTools)
|
||||
|
||||
@ -1,47 +1,42 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { getShikiInstance } from '@renderer/utils/shiki'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Card } from 'antd'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { npxFinder } from 'npx-scope-finder'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { FC, memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface McpDescriptionProps {
|
||||
searchKey: string
|
||||
}
|
||||
|
||||
const MCPDescription = ({ searchKey }: McpDescriptionProps) => {
|
||||
const [renderedMarkdown, setRenderedMarkdown] = useState('')
|
||||
const MCPDescription: FC<McpDescriptionProps> = ({ searchKey }) => {
|
||||
const { t } = useTranslation()
|
||||
const { shikiMarkdownIt } = useCodeStyle()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const md = useRef<MarkdownIt>(
|
||||
new MarkdownIt({
|
||||
linkify: true, // 自动转换 URL 为链接
|
||||
typographer: true // 启用印刷格式优化
|
||||
})
|
||||
)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const getMcpInfo = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const packages = await npxFinder(searchKey).finally(() => setLoading(false))
|
||||
const readme = packages[0]?.original?.readme ?? '暂无描述'
|
||||
setRenderedMarkdown(md.current.render(readme))
|
||||
}, [md, searchKey])
|
||||
const [mcpInfo, setMcpInfo] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
const sk = await getShikiInstance(theme)
|
||||
md.current.use(sk)
|
||||
getMcpInfo()
|
||||
})
|
||||
}, [getMcpInfo, theme])
|
||||
let isMounted = true
|
||||
setLoading(true)
|
||||
npxFinder(searchKey)
|
||||
.then((packages) => {
|
||||
const readme = packages[0]?.original?.readme ?? t('settings.mcp.noDescriptionAvailable')
|
||||
shikiMarkdownIt(readme).then((result) => {
|
||||
if (isMounted) setMcpInfo(result)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [shikiMarkdownIt, searchKey, t])
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Card loading={loading}>
|
||||
<div className="markdown" dangerouslySetInnerHTML={{ __html: renderedMarkdown }} />
|
||||
<div className="markdown" dangerouslySetInnerHTML={{ __html: mcpInfo }} />
|
||||
</Card>
|
||||
</Section>
|
||||
)
|
||||
@ -50,4 +45,4 @@ const Section = styled.div`
|
||||
padding-top: 8px;
|
||||
`
|
||||
|
||||
export default MCPDescription
|
||||
export default memo(MCPDescription)
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import {
|
||||
DEFAULT_LANGUAGES,
|
||||
DEFAULT_THEMES,
|
||||
getHighlighter,
|
||||
loadLanguageIfNeeded,
|
||||
loadThemeIfNeeded
|
||||
} from '@renderer/utils/shiki'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import type { HighlighterCore, SpecialLanguage, ThemedToken } from 'shiki/core'
|
||||
import type { HighlighterGeneric, ThemedToken } from 'shiki/core'
|
||||
|
||||
import { ShikiStreamTokenizer, ShikiStreamTokenizerOptions } from './ShikiStreamTokenizer'
|
||||
|
||||
@ -27,13 +34,8 @@ export interface HighlightChunkResult {
|
||||
* - 优先使用 Worker 处理高亮请求。
|
||||
*/
|
||||
class ShikiStreamService {
|
||||
// 默认配置
|
||||
private static readonly DEFAULT_LANGUAGES = ['javascript', 'typescript', 'python', 'java', 'markdown']
|
||||
private static readonly DEFAULT_THEMES = ['one-light', 'material-theme-darker']
|
||||
|
||||
// 主线程 highlighter 和 tokenizers
|
||||
private highlighter: HighlighterCore | null = null
|
||||
private highlighterInitPromise: Promise<void> | null = null
|
||||
private highlighter: HighlighterGeneric<any, any> | null = null
|
||||
|
||||
// 保存以 callerId-language-theme 为键的 tokenizer map
|
||||
private tokenizerCache = new LRUCache<string, ShikiStreamTokenizer>({
|
||||
@ -80,7 +82,7 @@ class ShikiStreamService {
|
||||
* 判断是否正在使用主线程高亮。外部不要依赖这个方法来判断。
|
||||
*/
|
||||
public hasMainHighlighter(): boolean {
|
||||
return !!this.highlighter && !this.highlighterInitPromise
|
||||
return !!this.highlighter
|
||||
}
|
||||
|
||||
/**
|
||||
@ -125,8 +127,8 @@ class ShikiStreamService {
|
||||
// 初始化 worker
|
||||
await this.sendWorkerMessage({
|
||||
type: 'init',
|
||||
languages: ShikiStreamService.DEFAULT_LANGUAGES,
|
||||
themes: ShikiStreamService.DEFAULT_THEMES
|
||||
languages: DEFAULT_LANGUAGES,
|
||||
themes: DEFAULT_THEMES
|
||||
})
|
||||
this.workerInitRetryCount = 0
|
||||
} catch (error) {
|
||||
@ -215,28 +217,6 @@ class ShikiStreamService {
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 highlighter
|
||||
*/
|
||||
private async initHighlighter(): Promise<void> {
|
||||
if (this.highlighterInitPromise) {
|
||||
return this.highlighterInitPromise
|
||||
} else if (this.highlighter) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.highlighterInitPromise = (async () => {
|
||||
const { createHighlighter } = await import('shiki')
|
||||
|
||||
this.highlighter = await createHighlighter({
|
||||
langs: ShikiStreamService.DEFAULT_LANGUAGES,
|
||||
themes: ShikiStreamService.DEFAULT_THEMES
|
||||
})
|
||||
})()
|
||||
|
||||
return this.highlighterInitPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 highlighter 已配置
|
||||
* @param language 语言
|
||||
@ -245,52 +225,15 @@ class ShikiStreamService {
|
||||
private async ensureHighlighterConfigured(
|
||||
language: string,
|
||||
theme: string
|
||||
): Promise<{ actualLanguage: string; actualTheme: string }> {
|
||||
// 确保 highlighter 已初始化
|
||||
if (!this.hasMainHighlighter()) {
|
||||
await this.initHighlighter()
|
||||
}
|
||||
|
||||
): Promise<{ loadedLanguage: string; loadedTheme: string }> {
|
||||
if (!this.highlighter) {
|
||||
throw new Error('Highlighter not initialized')
|
||||
this.highlighter = await getHighlighter()
|
||||
}
|
||||
|
||||
const shiki = await import('shiki')
|
||||
let actualLanguage = language
|
||||
let actualTheme = theme
|
||||
const loadedLanguage = await loadLanguageIfNeeded(this.highlighter, language)
|
||||
const loadedTheme = await loadThemeIfNeeded(this.highlighter, theme)
|
||||
|
||||
// 加载语言
|
||||
if (!this.highlighter.getLoadedLanguages().includes(language)) {
|
||||
try {
|
||||
if (['text', 'ansi'].includes(language)) {
|
||||
await this.highlighter.loadLanguage(language as SpecialLanguage)
|
||||
} else {
|
||||
const languageImportFn = shiki.bundledLanguages[language]
|
||||
const langData = await languageImportFn()
|
||||
await this.highlighter.loadLanguage(langData)
|
||||
}
|
||||
} catch (error) {
|
||||
await this.highlighter.loadLanguage('text')
|
||||
actualLanguage = 'text'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载主题
|
||||
if (!this.highlighter.getLoadedThemes().includes(theme)) {
|
||||
try {
|
||||
const themeImportFn = shiki.bundledThemes[theme]
|
||||
const themeData = await themeImportFn()
|
||||
await this.highlighter.loadTheme(themeData)
|
||||
} catch (error) {
|
||||
// 回退到 one-light
|
||||
console.debug(`Failed to load theme '${theme}', falling back to 'one-light':`, error)
|
||||
const oneLightTheme = await shiki.bundledThemes['one-light']()
|
||||
await this.highlighter.loadTheme(oneLightTheme)
|
||||
actualTheme = 'one-light'
|
||||
}
|
||||
}
|
||||
|
||||
return { actualLanguage, actualTheme }
|
||||
return { loadedLanguage, loadedTheme }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -303,15 +246,15 @@ class ShikiStreamService {
|
||||
* @returns pre 标签属性
|
||||
*/
|
||||
async getShikiPreProperties(language: string, theme: string): Promise<ShikiPreProperties> {
|
||||
const { actualLanguage, actualTheme } = await this.ensureHighlighterConfigured(language, theme)
|
||||
const { loadedLanguage, loadedTheme } = await this.ensureHighlighterConfigured(language, theme)
|
||||
|
||||
if (!this.highlighter) {
|
||||
throw new Error('Highlighter not initialized')
|
||||
}
|
||||
|
||||
const hast = this.highlighter.codeToHast('1', {
|
||||
lang: actualLanguage,
|
||||
theme: actualTheme
|
||||
lang: loadedLanguage,
|
||||
theme: loadedTheme
|
||||
})
|
||||
|
||||
// @ts-ignore hack
|
||||
@ -428,7 +371,7 @@ class ShikiStreamService {
|
||||
}
|
||||
|
||||
// 确保 highlighter 已配置
|
||||
const { actualLanguage, actualTheme } = await this.ensureHighlighterConfigured(language, theme)
|
||||
const { loadedLanguage, loadedTheme } = await this.ensureHighlighterConfigured(language, theme)
|
||||
|
||||
if (!this.highlighter) {
|
||||
throw new Error('Highlighter not initialized')
|
||||
@ -437,8 +380,8 @@ class ShikiStreamService {
|
||||
// 创建新的 tokenizer
|
||||
const options: ShikiStreamTokenizerOptions = {
|
||||
highlighter: this.highlighter,
|
||||
lang: actualLanguage,
|
||||
theme: actualTheme
|
||||
lang: loadedLanguage,
|
||||
theme: loadedTheme
|
||||
}
|
||||
|
||||
const tokenizer = new ShikiStreamTokenizer(options)
|
||||
@ -486,9 +429,7 @@ class ShikiStreamService {
|
||||
|
||||
this.workerDegradationCache.clear()
|
||||
this.tokenizerCache.clear()
|
||||
this.highlighter?.dispose()
|
||||
this.highlighter = null
|
||||
this.highlighterInitPromise = null
|
||||
this.workerInitPromise = null
|
||||
this.workerInitRetryCount = 0
|
||||
}
|
||||
|
||||
@ -256,7 +256,6 @@ describe('ShikiStreamService', () => {
|
||||
expect((shikiStreamService as any).highlighter).toBeNull()
|
||||
expect((shikiStreamService as any).tokenizerCache.size).toBe(0)
|
||||
expect((shikiStreamService as any).pendingRequests.size).toBe(0)
|
||||
expect((shikiStreamService as any).highlighterInitPromise).toBeNull()
|
||||
expect((shikiStreamService as any).workerInitPromise).toBeNull()
|
||||
expect((shikiStreamService as any).workerInitRetryCount).toBe(0)
|
||||
})
|
||||
|
||||
16
src/renderer/src/utils/asyncInitializer.ts
Normal file
16
src/renderer/src/utils/asyncInitializer.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export class AsyncInitializer<T> {
|
||||
private promise: Promise<T> | null = null
|
||||
private factory: () => Promise<T>
|
||||
|
||||
constructor(factory: () => Promise<T>) {
|
||||
this.factory = factory
|
||||
}
|
||||
|
||||
async get(): Promise<T> {
|
||||
if (!this.promise) {
|
||||
this.promise = this.factory()
|
||||
}
|
||||
// 如果需要允许重试,可以重置 this.promise 为 null。
|
||||
return this.promise
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null
|
||||
|
||||
export async function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
langs: ['javascript', 'typescript', 'python', 'java', 'markdown', 'json'],
|
||||
themes: ['one-light', 'material-theme-darker']
|
||||
})
|
||||
}
|
||||
|
||||
return await highlighterPromise
|
||||
}
|
||||
|
||||
export async function loadLanguageIfNeeded(highlighter: Highlighter, language: string) {
|
||||
if (!highlighter.getLoadedLanguages().includes(language)) {
|
||||
const languageImportFn = bundledLanguages[language]
|
||||
if (languageImportFn) {
|
||||
await highlighter.loadLanguage(await languageImportFn())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadThemeIfNeeded(highlighter: Highlighter, theme: string) {
|
||||
if (!highlighter.getLoadedThemes().includes(theme)) {
|
||||
const themeImportFn = bundledThemes[theme]
|
||||
if (themeImportFn) {
|
||||
await highlighter.loadTheme(await themeImportFn())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,100 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { setupMarkdownIt } from '@shikijs/markdown-it'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getTokenStyleObject, ThemedToken } from 'shiki/core'
|
||||
import { getTokenStyleObject, type HighlighterGeneric, SpecialLanguage, ThemedToken } from 'shiki/core'
|
||||
|
||||
import { runAsyncFunction } from '.'
|
||||
import { getHighlighter } from './highlighter'
|
||||
import { AsyncInitializer } from './asyncInitializer'
|
||||
|
||||
export const DEFAULT_LANGUAGES = ['javascript', 'typescript', 'python', 'java', 'markdown']
|
||||
export const DEFAULT_THEMES = ['one-light', 'material-theme-darker']
|
||||
|
||||
/**
|
||||
* shiki 初始化器,避免并发问题
|
||||
*/
|
||||
const shikiInitializer = new AsyncInitializer(async () => {
|
||||
const shiki = await import('shiki')
|
||||
return shiki
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取 shiki package
|
||||
*/
|
||||
export async function getShiki() {
|
||||
return shikiInitializer.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* shiki highlighter 初始化器,避免并发问题
|
||||
*/
|
||||
const highlighterInitializer = new AsyncInitializer(async () => {
|
||||
const shiki = await getShiki()
|
||||
return shiki.createHighlighter({
|
||||
langs: DEFAULT_LANGUAGES,
|
||||
themes: DEFAULT_THEMES
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取 shiki highlighter
|
||||
*/
|
||||
export async function getHighlighter() {
|
||||
return highlighterInitializer.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言
|
||||
* @param highlighter - shiki highlighter
|
||||
* @param language - 语言
|
||||
* @returns 实际加载的语言
|
||||
*/
|
||||
export async function loadLanguageIfNeeded(
|
||||
highlighter: HighlighterGeneric<any, any>,
|
||||
language: string
|
||||
): Promise<string> {
|
||||
const shiki = await getShiki()
|
||||
|
||||
let loadedLanguage = language
|
||||
if (!highlighter.getLoadedLanguages().includes(language)) {
|
||||
try {
|
||||
if (['text', 'ansi'].includes(language)) {
|
||||
await highlighter.loadLanguage(language as SpecialLanguage)
|
||||
} else {
|
||||
const languageImportFn = shiki.bundledLanguages[language]
|
||||
const langData = await languageImportFn()
|
||||
await highlighter.loadLanguage(langData)
|
||||
}
|
||||
} catch (error) {
|
||||
await highlighter.loadLanguage('text')
|
||||
loadedLanguage = 'text'
|
||||
}
|
||||
}
|
||||
|
||||
return loadedLanguage
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载主题
|
||||
* @param highlighter - shiki highlighter
|
||||
* @param theme - 主题
|
||||
* @returns 实际加载的主题
|
||||
*/
|
||||
export async function loadThemeIfNeeded(highlighter: HighlighterGeneric<any, any>, theme: string): Promise<string> {
|
||||
const shiki = await getShiki()
|
||||
|
||||
let loadedTheme = theme
|
||||
if (!highlighter.getLoadedThemes().includes(theme)) {
|
||||
try {
|
||||
const themeImportFn = shiki.bundledThemes[theme]
|
||||
const themeData = await themeImportFn()
|
||||
await highlighter.loadTheme(themeData)
|
||||
} catch (error) {
|
||||
// 回退到 one-light
|
||||
console.debug(`Failed to load theme '${theme}', falling back to 'one-light':`, error)
|
||||
const oneLightTheme = await shiki.bundledThemes['one-light']()
|
||||
await highlighter.loadTheme(oneLightTheme)
|
||||
loadedTheme = 'one-light'
|
||||
}
|
||||
}
|
||||
|
||||
return loadedTheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Shiki token 样式转换为 React 样式对象
|
||||
@ -38,44 +126,35 @@ export function getReactStyleFromToken(token: ThemedToken): Record<string, strin
|
||||
return reactStyle
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'material-theme-darker'
|
||||
},
|
||||
defaultColor: 'light'
|
||||
}
|
||||
/**
|
||||
* 获取 markdown-it,避免并发问题
|
||||
*/
|
||||
const mdInitializer = new AsyncInitializer(async () => {
|
||||
const md = await import('markdown-it')
|
||||
return md.default({
|
||||
linkify: true, // 自动转换 URL 为链接
|
||||
typographer: true // 启用印刷格式优化
|
||||
})
|
||||
})
|
||||
|
||||
export async function getShikiInstance(theme: ThemeMode) {
|
||||
/**
|
||||
* 获取 markdown-it 渲染器
|
||||
* @param theme - 主题
|
||||
*/
|
||||
export async function getMarkdownIt(theme: string) {
|
||||
const highlighter = await getHighlighter()
|
||||
const md = await mdInitializer.get()
|
||||
const { fromHighlighter } = await import('@shikijs/markdown-it/core')
|
||||
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
defaultColor: theme
|
||||
}
|
||||
|
||||
return function (markdownit: MarkdownIt) {
|
||||
setupMarkdownIt(markdownit, highlighter, options)
|
||||
}
|
||||
}
|
||||
|
||||
export function useShikiWithMarkdownIt(content: string) {
|
||||
const [renderedMarkdown, setRenderedMarkdown] = useState('')
|
||||
const md = useRef<MarkdownIt>(
|
||||
new MarkdownIt({
|
||||
linkify: true, // 自动转换 URL 为链接
|
||||
typographer: true // 启用印刷格式优化
|
||||
md.use(
|
||||
fromHighlighter(highlighter, {
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'material-theme-darker'
|
||||
},
|
||||
defaultColor: theme
|
||||
})
|
||||
)
|
||||
const { theme } = useTheme()
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
const sk = await getShikiInstance(theme)
|
||||
md.current.use(sk)
|
||||
setRenderedMarkdown(md.current.render(content))
|
||||
})
|
||||
}, [content, theme])
|
||||
return {
|
||||
renderedMarkdown
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
120
yarn.lock
120
yarn.lock
@ -3986,79 +3986,79 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/core@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/core@npm:3.2.2"
|
||||
"@shikijs/core@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/core@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
hast-util-to-html: "npm:^9.0.5"
|
||||
checksum: 10c0/69afe788994653b69f1bafd4fd3c2143609b4b0c05e970c8dc8d82ec660d617850ad9eeb2b6aa5be2dd534cefa0213d577129cb9ae1070eb4890cbbf1ac0f63e
|
||||
checksum: 10c0/702469d9c80fc80e2b81dd10407cc946771dcf355d56048e1dab43e40d144395c14a6ecde92e03c70a35249ad6634ef4605bd17ad6974a2b4e04f9efccf24414
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-javascript@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/engine-javascript@npm:3.2.2"
|
||||
"@shikijs/engine-javascript@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/engine-javascript@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
oniguruma-to-es: "npm:^4.1.0"
|
||||
checksum: 10c0/2db8f9c04cc8e40352eb69ddd4de81bda95c5318c897f43215622352c91591974522cefb3959bcfaa183fd36a18d1af9e704289ed7999273dcd763bfaa5a1827
|
||||
oniguruma-to-es: "npm:^4.3.3"
|
||||
checksum: 10c0/160056a6303978d4e40114fe0414acd5089ea39a55a3144b9cba5e50aa38c521948ee47a2edc5acda5fd3607e33b20539845cfd9ca3508163e989b8fb4220488
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-oniguruma@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.2.2"
|
||||
"@shikijs/engine-oniguruma@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
checksum: 10c0/b5eedfca26f7e1525fd079c1827ae9bdedafb574ce4eb535c54d484218b7428fb9ac93607f79a2adc1482483dd0366fdf07b0846403a76cd4767649adb8fa590
|
||||
checksum: 10c0/b8a13123b8a41e1016b661c24b349163b5026841772c351aacddcdc724518a926a49065ac77e4a1d4bb94da12c6bf11e6b1c938ef881545064bb3b484223eba0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/langs@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/langs@npm:3.2.2"
|
||||
"@shikijs/langs@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/langs@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
checksum: 10c0/04b5c9b92de9070624d24e20a2b3607edcbe4894a1db8056927f0d0637f080e2eed4e54925f0ded36874361db14bab9e4d9c2d06614ddd733f3f314250eabaf8
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
checksum: 10c0/ca0260b00e32385db8db43d8dd147f480bc2ff699acaf6052ec3e421b1c6d27df6dfb0f69fadb673ef357333ba65fdce2fbcd8c31c7d245439756bfb3530eba4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/markdown-it@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/markdown-it@npm:3.2.2"
|
||||
"@shikijs/markdown-it@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/markdown-it@npm:3.4.2"
|
||||
dependencies:
|
||||
markdown-it: "npm:^14.1.0"
|
||||
shiki: "npm:3.2.2"
|
||||
shiki: "npm:3.4.2"
|
||||
peerDependencies:
|
||||
markdown-it-async: ^2.2.0
|
||||
peerDependenciesMeta:
|
||||
markdown-it-async:
|
||||
optional: true
|
||||
checksum: 10c0/37c98e45a0905ea58f605c7cd341c83f3289b6a37093862535c59f7cc178fe9bfb13413fea68d4d923341e51b2e5718fc5172147d15e07457a76aceed2ac1f95
|
||||
checksum: 10c0/6bdb4fae6038867a370b454d39cb64ac78645385f3f0150e85fd68952965186a00ba87aa4ce1d0b280f599d6aaf78d30ee32b6ad7d92391091dd7af569e7cbdb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/themes@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/themes@npm:3.2.2"
|
||||
"@shikijs/themes@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/themes@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
checksum: 10c0/93745e76e7ed6cab1d797ec68b53a0a183d989201e5064b33a78b516e128848d2c9be194d29cf602d5017dc2a74013699c773d052aeb45593851ae35b035afaa
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
checksum: 10c0/d50bca4384ccf88d68f007869e13bc7a9b55b16c40a3269fe120b2e5a2e882f6206ee0325f619bfa31ff00a0341452840d38f4ca2296dd3ba3e200e53445e22b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/types@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@shikijs/types@npm:3.2.2"
|
||||
"@shikijs/types@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/types@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10c0/aec3327d0cfc89af138ce195ac070ba62d8229864c079a3f06dff5a180036fdd963282068d67bd4c89a04ae688005c2b7c214c274ad0bb265f6f7ab6907a67a6
|
||||
checksum: 10c0/a20d3535cc0d61a55d0c0d4dfcd33a52229ec8a4c650613cb0f424dcb499bcdf0230e007f70a18e12c102a04820557ff120f41f18b15a94f95f9ec343592906b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -5765,7 +5765,7 @@ __metadata:
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
"@reduxjs/toolkit": "npm:^2.2.5"
|
||||
"@shikijs/markdown-it": "npm:^3.2.2"
|
||||
"@shikijs/markdown-it": "npm:^3.4.2"
|
||||
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
|
||||
"@swc/plugin-styled-components": "npm:^7.1.5"
|
||||
"@tanstack/react-query": "npm:^5.27.0"
|
||||
@ -5866,7 +5866,7 @@ __metadata:
|
||||
remark-math: "npm:^6.0.0"
|
||||
rollup-plugin-visualizer: "npm:^5.12.0"
|
||||
sass: "npm:^1.88.0"
|
||||
shiki: "npm:^3.2.2"
|
||||
shiki: "npm:^3.4.2"
|
||||
string-width: "npm:^7.2.0"
|
||||
styled-components: "npm:^6.1.11"
|
||||
tar: "npm:^7.4.3"
|
||||
@ -8960,13 +8960,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emoji-regex-xs@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "emoji-regex-xs@npm:1.0.0"
|
||||
checksum: 10c0/1082de006991eb05a3324ef0efe1950c7cdf66efc01d4578de82b0d0d62add4e55e97695a8a7eeda826c305081562dc79b477ddf18d886da77f3ba08c4b940a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emoji-regex@npm:^10.3.0":
|
||||
version: 10.4.0
|
||||
resolution: "emoji-regex@npm:10.4.0"
|
||||
@ -14793,22 +14786,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"oniguruma-parser@npm:^0.11.0":
|
||||
version: 0.11.1
|
||||
resolution: "oniguruma-parser@npm:0.11.1"
|
||||
checksum: 10c0/d721cabe5632d0b772fec95dd6920cb6d6ba7a8e9b247dbb32a82b8a997137ecb62110d1788578dfd43596d4461a3319ca320d30aa2b6ebbaa19552a98e507ba
|
||||
"oniguruma-parser@npm:^0.12.1":
|
||||
version: 0.12.1
|
||||
resolution: "oniguruma-parser@npm:0.12.1"
|
||||
checksum: 10c0/b843ea54cda833efb19f856314afcbd43e903ece3de489ab78c527ddec84859208052557daa9fad4bdba89ebdd15b0cc250de86b3daf8c7cbe37bac5a6a185d3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"oniguruma-to-es@npm:^4.1.0":
|
||||
version: 4.2.0
|
||||
resolution: "oniguruma-to-es@npm:4.2.0"
|
||||
"oniguruma-to-es@npm:^4.3.3":
|
||||
version: 4.3.3
|
||||
resolution: "oniguruma-to-es@npm:4.3.3"
|
||||
dependencies:
|
||||
emoji-regex-xs: "npm:^1.0.0"
|
||||
oniguruma-parser: "npm:^0.11.0"
|
||||
oniguruma-parser: "npm:^0.12.1"
|
||||
regex: "npm:^6.0.1"
|
||||
regex-recursion: "npm:^6.0.2"
|
||||
checksum: 10c0/a2c505ad9d4ccca9f71a5ea22dc68ab94f244e2fab5b04fea54f355411a2c13d65b5c28925af508ea3a694ce8cf9e86931681bfe3ea4a89722d9b50e24bf21fd
|
||||
checksum: 10c0/bc034e84dfee4dbc061cf6364023e66e1667fb8dc3afcad3b7d6a2c77e2d4a4809396ee2fb8c1fd3d6f00f76f7ca14b773586bf862c5f0c0074c059e2a219252
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -17618,19 +17610,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shiki@npm:3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "shiki@npm:3.2.2"
|
||||
"shiki@npm:3.4.2, shiki@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "shiki@npm:3.4.2"
|
||||
dependencies:
|
||||
"@shikijs/core": "npm:3.2.2"
|
||||
"@shikijs/engine-javascript": "npm:3.2.2"
|
||||
"@shikijs/engine-oniguruma": "npm:3.2.2"
|
||||
"@shikijs/langs": "npm:3.2.2"
|
||||
"@shikijs/themes": "npm:3.2.2"
|
||||
"@shikijs/types": "npm:3.2.2"
|
||||
"@shikijs/core": "npm:3.4.2"
|
||||
"@shikijs/engine-javascript": "npm:3.4.2"
|
||||
"@shikijs/engine-oniguruma": "npm:3.4.2"
|
||||
"@shikijs/langs": "npm:3.4.2"
|
||||
"@shikijs/themes": "npm:3.4.2"
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10c0/0183f889029ff1d14f79aa34e36f1e5a67b667661422f8a7de8936164099827588df7b2b4ed6835ad2eb3efb11ea882b4cb8022550503108c958a796df01f35c
|
||||
checksum: 10c0/3cae825d8c341d7334e541efad30125fac0064db6004359e661a594782d59f93f66f2dcb5dbc1d8cb6508c43ccdd03ed6cf1d22306b382bc1f395a6130e5cbbb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user