diff --git a/src/features/settings/components/AppearanceSettings.tsx b/src/features/settings/components/AppearanceSettings.tsx index 5d06a7f9..44e996c0 100644 --- a/src/features/settings/components/AppearanceSettings.tsx +++ b/src/features/settings/components/AppearanceSettings.tsx @@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next' import { Button } from '../../../components/ui/Button' import { SunIcon, MoonIcon, SystemIcon, CheckIcon, ChevronDownIcon } from '../../../components/Icons' import { Toggle, SegmentedControl, SettingRow, SettingsSection } from './SettingsUI' +import { CodeBlockThemeSettings } from './CodeBlockThemeSettings' import { useTheme } from '../../../hooks' import { getThemePreset } from '../../../themes' import type { CustomCSSSnippet } from '../../../store/themeStore' @@ -726,6 +727,8 @@ export function AppearanceSettings() { + + ) } diff --git a/src/features/settings/components/CodeBlockThemeSettings.tsx b/src/features/settings/components/CodeBlockThemeSettings.tsx new file mode 100644 index 00000000..930bbf31 --- /dev/null +++ b/src/features/settings/components/CodeBlockThemeSettings.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SettingRow, SettingsSection } from './SettingsUI' +import { useTheme } from '../../../hooks' +import { + AVAILABLE_CODE_BLOCK_THEMES, + filterThemesByType, + type CodeBlockThemeInfo, +} from '../../../lib/codeBlockThemes' +import { highlightHtmlInWorker } from '../../../lib/shikiWorkerClient' +import { ChevronDownIcon } from '../../../components/Icons' + +// 共享的预览代码片段:覆盖关键字、字符串、注释、数字、函数调用、属性等常见 token +const PREVIEW_CODE = `// greet user by name +function greet(name: string): string { + const message = \`Hello, \${name}!\` + return message +} + +const result = greet("world") +console.log(result)` + +const PREVIEW_LANGUAGE = 'ts' + +function themeDisplayName(id: string): string { + return AVAILABLE_CODE_BLOCK_THEMES.find(t => t.id === id)?.displayName ?? id +} + +// ============================================ +// Theme select dropdown +// ============================================ + +function CodeBlockThemeSelect({ + value, + onChange, + type, +}: { + value: string + onChange: (id: string) => void + type: 'light' | 'dark' +}) { + // 同 type 的主题作为默认推荐组,其它 type 作为另一组放下面(用户仍可混搭) + const sameType = useMemo(() => filterThemesByType(type), [type]) + const otherType = useMemo(() => filterThemesByType(type === 'light' ? 'dark' : 'light'), [type]) + + return ( +
+ + +
+ ) +} + +// ============================================ +// Live preview using Shiki +// ============================================ + +function CodeBlockPreview({ themeId, label }: { themeId: string; label: string }) { + const [html, setHtml] = useState(null) + const [error, setError] = useState(null) + const requestKeyRef = useRef(0) + + useEffect(() => { + const key = `preview-${themeId}` + const myKey = ++requestKeyRef.current + + let cancelled = false + highlightHtmlInWorker({ + key, + text: PREVIEW_CODE, + language: PREVIEW_LANGUAGE, + theme: themeId as Parameters[0]['theme'], + }) + .then(result => { + if (cancelled || myKey !== requestKeyRef.current) return + setHtml(result.html) + setError(null) + }) + .catch(err => { + if (cancelled || myKey !== requestKeyRef.current) return + setError(err instanceof Error ? err.message : String(err)) + setHtml(null) + }) + + return () => { + cancelled = true + } + }, [themeId]) + + return ( +
+
+

{label}

+

{themeDisplayName(themeId)}

+
+
+ {html ? ( +
自带 inline style (bg/fg/color),直接渲染 + dangerouslySetInnerHTML={{ __html: html }} + /> + ) : error ? ( +
{error}
+ ) : ( +
+            {PREVIEW_CODE}
+          
+ )} +
+
+ ) +} + +// ============================================ +// Main section +// ============================================ + +export function CodeBlockThemeSettings() { + const { t } = useTranslation(['settings', 'common']) + const { + codeBlockThemeLight, + codeBlockThemeDark, + setCodeBlockThemeLight, + setCodeBlockThemeDark, + resolvedTheme, + } = useTheme() + + return ( + +

{t('appearance.codeBlockThemesDesc')}

+ + + + + + + + + +
+ +
+
+ ) +} + +// 导出 unused type 仅用于未来扩展(被引用以避免 tree-shake 误删) +export type { CodeBlockThemeInfo } diff --git a/src/hooks/useSyntaxHighlight.test.ts b/src/hooks/useSyntaxHighlight.test.ts index 3bd42d5f..062752be 100644 --- a/src/hooks/useSyntaxHighlight.test.ts +++ b/src/hooks/useSyntaxHighlight.test.ts @@ -12,6 +12,16 @@ describe('getShikiTheme', () => { expect(getShikiTheme(true).key).toBe('github-dark-default') expect(getShikiTheme(false).key).toBe('github-light-default') }) + + it('respects user-configured light/dark themes', () => { + expect(getShikiTheme(false, 'one-light', 'one-dark-pro').theme).toBe('one-light') + expect(getShikiTheme(true, 'one-light', 'one-dark-pro').theme).toBe('one-dark-pro') + }) + + it('falls back to GitHub Default when given an unknown theme id', () => { + expect(getShikiTheme(false, 'not-a-real-theme', 'also-fake').theme).toBe('github-light-default') + expect(getShikiTheme(true, 'not-a-real-theme', 'also-fake').theme).toBe('github-dark-default') + }) }) describe('Shiki language metadata', () => { diff --git a/src/hooks/useSyntaxHighlight.ts b/src/hooks/useSyntaxHighlight.ts index e08a97c4..89f31201 100644 --- a/src/hooks/useSyntaxHighlight.ts +++ b/src/hooks/useSyntaxHighlight.ts @@ -1,14 +1,31 @@ -import { useState, useEffect, useMemo, useRef, useId } from 'react' +import { useState, useEffect, useMemo, useRef, useId, useSyncExternalStore } from 'react' import type { ShikiThemeInput } from '../lib/shikiTheme' import { getShikiTheme, useIsDarkMode } from '../lib/shikiTheme' import { disposeShikiWorkerKey, highlightHtmlInWorker, highlightTokensInWorker } from '../lib/shikiWorkerClient' import type { HighlightTokens } from '../lib/highlightTypes' import { normalizeLanguage } from '../utils/languageUtils' import { THEME_SWITCH_DISABLE_MS } from '../constants' +import { themeStore } from '../store/themeStore' export type { HighlightTokens } from '../lib/highlightTypes' export type { ShikiThemeInput } from '../lib/shikiTheme' +// ============================================ +// 代码块主题订阅(仅在 codeBlockThemeLight/Dark 变化时触发 re-render) +// ============================================ + +function codeBlockThemeKey(): string { + const s = themeStore.getState() + return s.codeBlockThemeLight + '|' + s.codeBlockThemeDark +} + +function useCodeBlockThemes(): { light: string; dark: string } { + // 订阅派生字符串,避免其它 appearance 字段变化时让所有代码块重渲染 + useSyncExternalStore(themeStore.subscribe, codeBlockThemeKey) + const s = themeStore.getState() + return { light: s.codeBlockThemeLight, dark: s.codeBlockThemeDark } +} + type IdleWindowApi = { requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number cancelIdleCallback?: (id: number) => void @@ -210,11 +227,12 @@ export function useStreamingSyntaxHighlight( const { lang = 'text', theme, enabled = true } = options const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const instanceId = useId() const resolvedTheme = useMemo(() => { if (theme) return { theme, key: theme } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const [outputState, setOutputState] = useState<{ code: string; tokens: HighlightTokens } | null>(null) const [isLoading, setIsLoading] = useState(false) @@ -295,13 +313,14 @@ export function useSyntaxHighlight(code: string, options: HighlightOptions & { m const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const resolvedTheme = useMemo(() => { if (theme) { return { theme, key: theme } } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const cacheKey = useMemo( () => getCacheKey(code, normalizedLang, resolvedTheme.key), @@ -406,12 +425,13 @@ export function useSyntaxHighlightRef( const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const resolvedTheme = useMemo(() => { if (theme) { return { theme, key: theme } } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const tokensRef = useRef(null) const [version, setVersion] = useState(0) diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index cc30fbbd..97301aa2 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -238,6 +238,14 @@ export function useTheme() { themeStore.setProcessCollapseEnabled(enabled) }, []) + const setCodeBlockThemeLight = useCallback((id: string) => { + themeStore.setCodeBlockThemeLight(id) + }, []) + + const setCodeBlockThemeDark = useCallback((id: string) => { + themeStore.setCodeBlockThemeDark(id) + }, []) + return { // 日夜模式(向后兼容) mode: state.colorMode, @@ -352,5 +360,11 @@ export function useTheme() { // 过程折叠 processCollapseEnabled: state.processCollapseEnabled, setProcessCollapseEnabled, + + // 代码块主题(Shiki) + codeBlockThemeLight: state.codeBlockThemeLight, + codeBlockThemeDark: state.codeBlockThemeDark, + setCodeBlockThemeLight, + setCodeBlockThemeDark, } } diff --git a/src/lib/codeBlockThemes.ts b/src/lib/codeBlockThemes.ts new file mode 100644 index 00000000..39fb7012 --- /dev/null +++ b/src/lib/codeBlockThemes.ts @@ -0,0 +1,38 @@ +/** + * Code block (Shiki) theme catalog + helpers. + * + * `bundledThemesInfo` 来自 `shiki/themes`,包含全部 65 个内置 Shiki 主题的 + * `{ id, displayName, type }` 元数据。Worker 端使用同源的 lazy `import` 字段 + * 按需加载,主线程只读元数据用于下拉菜单。 + */ + +import { bundledThemesInfo } from 'shiki/themes' +import type { BundledTheme } from 'shiki/themes' + +export type ShikiThemeType = 'light' | 'dark' + +export interface CodeBlockThemeInfo { + id: BundledTheme + displayName: string + type: ShikiThemeType +} + +/** 全部 Shiki 内置主题元数据,按 displayName 字母序排序 */ +export const AVAILABLE_CODE_BLOCK_THEMES: readonly CodeBlockThemeInfo[] = bundledThemesInfo + .map(t => ({ id: t.id as BundledTheme, displayName: t.displayName, type: t.type as ShikiThemeType })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + +export const DEFAULT_CODE_BLOCK_THEME_LIGHT = 'github-light-default' as const +export const DEFAULT_CODE_BLOCK_THEME_DARK = 'github-dark-default' as const + +const knownIds = new Set(AVAILABLE_CODE_BLOCK_THEMES.map(t => t.id)) + +/** 校验 Shiki theme id 是否存在;不存在则回退到对应默认值 */ +export function normalizeCodeBlockTheme(id: string, fallback: BundledTheme): BundledTheme { + return knownIds.has(id) ? (id as BundledTheme) : fallback +} + +/** 按 type 过滤(light/dark) */ +export function filterThemesByType(type: ShikiThemeType): readonly CodeBlockThemeInfo[] { + return AVAILABLE_CODE_BLOCK_THEMES.filter(t => t.type === type) +} diff --git a/src/lib/shikiTheme.ts b/src/lib/shikiTheme.ts index aaa05fa9..f4183d0b 100644 --- a/src/lib/shikiTheme.ts +++ b/src/lib/shikiTheme.ts @@ -1,10 +1,25 @@ import { useState, useEffect } from 'react' import type { BundledTheme } from 'shiki/themes' +import { + DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + normalizeCodeBlockTheme, +} from './codeBlockThemes' export type ShikiThemeInput = BundledTheme -export function getShikiTheme(isDark: boolean): { theme: ShikiThemeInput; key: string } { - const theme = isDark ? 'github-dark-default' : 'github-light-default' +/** + * 根据 isDark + 用户在设置里选择的代码块主题解析出实际使用的 Shiki 主题。 + * 入参为空字符串/无效值时回退到 GitHub Default。 + */ +export function getShikiTheme( + isDark: boolean, + codeBlockThemeLight: string = DEFAULT_CODE_BLOCK_THEME_LIGHT, + codeBlockThemeDark: string = DEFAULT_CODE_BLOCK_THEME_DARK, +): { theme: ShikiThemeInput; key: string } { + const fallback = isDark ? DEFAULT_CODE_BLOCK_THEME_DARK : DEFAULT_CODE_BLOCK_THEME_LIGHT + const requested = isDark ? codeBlockThemeDark : codeBlockThemeLight + const theme = normalizeCodeBlockTheme(requested, fallback) return { theme, key: theme } } diff --git a/src/lib/shikiWorkerClient.ts b/src/lib/shikiWorkerClient.ts index c3f734f6..f050628c 100644 --- a/src/lib/shikiWorkerClient.ts +++ b/src/lib/shikiWorkerClient.ts @@ -1,6 +1,11 @@ import type { BundledTheme } from 'shiki/themes' import type { WorkerRequest, WorkerResponse, WorkerToken } from '../workers/shikiWorker' import type { HighlightTokens } from './highlightTypes' +import { + DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + normalizeCodeBlockTheme, +} from './codeBlockThemes' type PendingRequest = { resolve: (response: WorkerResponse) => void @@ -73,7 +78,18 @@ export function ensureShikiWorkerReady(): Promise { workerReadyPromiseResolve = resolve workerReadyPromiseReject = reject }) - getWorker().postMessage({ type: 'init', themes: ['github-dark-default', 'github-light-default'] } satisfies WorkerRequest) + // 预加载用户当前选择的主题;其他主题在第一次 highlight 时 lazy load。 + // 用 localStorage 直接读避免循环依赖(themeStore 也会反向引用此模块树)。 + const light = normalizeCodeBlockTheme( + typeof localStorage !== 'undefined' && localStorage.getItem('code-block-theme-light') || DEFAULT_CODE_BLOCK_THEME_LIGHT, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + ) + const dark = normalizeCodeBlockTheme( + typeof localStorage !== 'undefined' && localStorage.getItem('code-block-theme-dark') || DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_DARK, + ) + const themes = Array.from(new Set([light, dark])) + getWorker().postMessage({ type: 'init', themes } satisfies WorkerRequest) return workerReady } diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 83e09c9b..4a8910c6 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -202,6 +202,14 @@ "uiFontScaleDesc": "Adjust the font size for menus, labels, and other UI elements", "codeFontScale": "Code Font Size", "codeFontScaleDesc": "Adjust the font size for code blocks, diffs, and terminal", + "codeBlockThemes": "Code Block Theme", + "codeBlockThemesDesc": "Pick the syntax highlighting theme used in fenced code blocks. Light and dark are configured independently and switch with your color mode.", + "codeBlockThemeLight": "Light Mode Code Block", + "codeBlockThemeLightDesc": "Syntax theme used when the color mode resolves to light", + "codeBlockThemeDark": "Dark Mode Code Block", + "codeBlockThemeDarkDesc": "Syntax theme used when the color mode resolves to dark", + "codeBlockPreviewLight": "Light preview", + "codeBlockPreviewDark": "Dark preview", "fontScaleReset": "Reset to default", "codeWordWrap": "Code Word Wrap", "codeWordWrapDesc": "Wrap code blocks and diffs to the available width to reduce horizontal scrolling", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 2153983e..0ce073b6 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -202,6 +202,14 @@ "uiFontScaleDesc": "调整菜单、标签等 UI 元素的字体大小", "codeFontScale": "代码字号", "codeFontScaleDesc": "调整代码块、差异对比和终端的字体大小", + "codeBlockThemes": "代码块主题", + "codeBlockThemesDesc": "选择 fenced 代码块使用的语法高亮主题。亮色和暗色可分别设置,并随颜色模式自动切换。", + "codeBlockThemeLight": "亮色模式代码块", + "codeBlockThemeLightDesc": "颜色模式为亮色时使用的语法主题", + "codeBlockThemeDark": "暗色模式代码块", + "codeBlockThemeDarkDesc": "颜色模式为暗色时使用的语法主题", + "codeBlockPreviewLight": "亮色预览", + "codeBlockPreviewDark": "暗色预览", "fontScaleReset": "恢复默认", "codeWordWrap": "代码自动换行", "codeWordWrapDesc": "让代码块和 diff 在可用宽度内自动换行,减少横向滚动", diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 18494377..b34c7921 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -128,6 +128,9 @@ const DEFAULT_COMPACT_INLINE_PERMISSION = false const DEFAULT_GLASS_EFFECT = true const DEFAULT_QUEUE_FOLLOWUP_MESSAGES = false const DEFAULT_MANUAL_TERMINAL_TITLES = false +/** Shiki 代码块主题:默认 GitHub,保留现有行为 */ +const DEFAULT_CODE_BLOCK_THEME_LIGHT = 'github-light-default' +const DEFAULT_CODE_BLOCK_THEME_DARK = 'github-dark-default' const DEFAULT_EXTERNAL_FILE_DROP_MODE: ExternalFileDropMode = 'upload-first' const DEFAULT_OUTLINE_CURRENT_HIGHLIGHT = true /** 连续助手消息时,仅在回合末尾显示分叉/复制按钮 */ @@ -194,6 +197,10 @@ export interface ThemeState { desktopCollapsedInputDock: boolean /** 过程折叠:用户发送后显示 Working 计时,结束后收成折叠块,最终回答留在外面 */ processCollapseEnabled: boolean + /** 代码块语法高亮主题(亮色模式),Shiki theme id */ + codeBlockThemeLight: string + /** 代码块语法高亮主题(暗色模式),Shiki theme id */ + codeBlockThemeDark: string } export type ThemeBackup = ThemeState @@ -230,6 +237,8 @@ const STORAGE_KEY_OUTLINE_CURRENT_HIGHLIGHT = 'outline-current-highlight' const STORAGE_KEY_ACTIONS_ON_LATEST_ASSISTANT_ONLY = 'actions-on-latest-assistant-only' const STORAGE_KEY_DESKTOP_COLLAPSED_INPUT_DOCK = 'desktop-collapsed-input-dock' const STORAGE_KEY_PROCESS_COLLAPSE_ENABLED = 'process-collapse-enabled' +const STORAGE_KEY_CODE_BLOCK_THEME_LIGHT = 'code-block-theme-light' +const STORAGE_KEY_CODE_BLOCK_THEME_DARK = 'code-block-theme-dark' // ============================================ // DOM Style Element IDs @@ -377,6 +386,11 @@ class ThemeStore { ? DEFAULT_PROCESS_COLLAPSE_ENABLED : savedProcessCollapseEnabled === 'true' + const savedCodeBlockThemeLight = localStorage.getItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT) + const codeBlockThemeLight = savedCodeBlockThemeLight || DEFAULT_CODE_BLOCK_THEME_LIGHT + const savedCodeBlockThemeDark = localStorage.getItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK) + const codeBlockThemeDark = savedCodeBlockThemeDark || DEFAULT_CODE_BLOCK_THEME_DARK + this.state = { presetId: normalizedPreset, colorMode: savedMode, @@ -406,6 +420,8 @@ class ThemeStore { actionsOnLatestAssistantOnly, desktopCollapsedInputDock, processCollapseEnabled, + codeBlockThemeLight, + codeBlockThemeDark, } } @@ -503,6 +519,14 @@ class ThemeStore { return this.state.processCollapseEnabled } + get codeBlockThemeLight() { + return this.state.codeBlockThemeLight + } + + get codeBlockThemeDark() { + return this.state.codeBlockThemeDark + } + /** 获取当前主题预设(内置主题返回对象,自定义返回 undefined) */ getPreset(): ThemePreset | undefined { return getThemePreset(this.state.presetId) @@ -804,6 +828,20 @@ class ThemeStore { this.emit() } + setCodeBlockThemeLight(id: string) { + if (this.state.codeBlockThemeLight === id) return + this.state = { ...this.state, codeBlockThemeLight: id } + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT, id) + this.emit() + } + + setCodeBlockThemeDark(id: string) { + if (this.state.codeBlockThemeDark === id) return + this.state = { ...this.state, codeBlockThemeDark: id } + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK, id) + this.emit() + } + // ---- Theme Application ---- /** 初始化:应用当前主题到 DOM */ @@ -1063,6 +1101,14 @@ function normalizeThemeBackup(raw: unknown): ThemeBackup { typeof parsed?.processCollapseEnabled === 'boolean' ? parsed.processCollapseEnabled : DEFAULT_PROCESS_COLLAPSE_ENABLED, + codeBlockThemeLight: + typeof parsed?.codeBlockThemeLight === 'string' && parsed.codeBlockThemeLight + ? parsed.codeBlockThemeLight + : DEFAULT_CODE_BLOCK_THEME_LIGHT, + codeBlockThemeDark: + typeof parsed?.codeBlockThemeDark === 'string' && parsed.codeBlockThemeDark + ? parsed.codeBlockThemeDark + : DEFAULT_CODE_BLOCK_THEME_DARK, } } @@ -1112,4 +1158,6 @@ export function importThemeBackup(raw: unknown): void { ) localStorage.setItem(STORAGE_KEY_DESKTOP_COLLAPSED_INPUT_DOCK, String(backup.desktopCollapsedInputDock)) localStorage.setItem(STORAGE_KEY_PROCESS_COLLAPSE_ENABLED, String(backup.processCollapseEnabled)) + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT, backup.codeBlockThemeLight) + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK, backup.codeBlockThemeDark) } diff --git a/src/workers/shikiWorker.ts b/src/workers/shikiWorker.ts index 751957e9..67236ad4 100644 --- a/src/workers/shikiWorker.ts +++ b/src/workers/shikiWorker.ts @@ -9,7 +9,7 @@ import { import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import onigWasmUrl from 'shiki/onig.wasm?url' import { bundledLanguagesAlias, bundledLanguagesBase } from 'shiki/langs' -import type { BundledTheme } from 'shiki/themes' +import { bundledThemesInfo, type BundledTheme } from 'shiki/themes' export type WorkerToken = [content: string, color: string] @@ -82,6 +82,38 @@ async function ensureLang(instance: HighlighterCore, lang: string): Promise")` 静态字面量),让 Vite 为每个主题 + * 生成独立 chunk,按需 lazy-load。预加载过的主题记录在 loadedThemes 里。 + */ +const themeImporters = new Map Promise<{ default: unknown }>>( + bundledThemesInfo.map(t => [t.id, t.import as () => Promise<{ default: unknown }>]), +) +const loadedThemes = new Set() +const pendingThemeLoads = new Map>() + +async function ensureTheme(instance: HighlighterCore, theme: string): Promise { + if (loadedThemes.has(theme)) return + const existing = pendingThemeLoads.get(theme) + if (existing) return existing + + const importer = themeImporters.get(theme) + if (!importer) throw new Error(`Unknown Shiki theme: ${theme}`) + + const promise = (async () => { + const mod = await importer() + await instance.loadTheme(mod.default as Parameters[0]) + loadedThemes.add(theme) + })() + pendingThemeLoads.set(theme, promise) + try { + await promise + } finally { + pendingThemeLoads.delete(theme) + } +} + function toWorkerToken(value: ThemedToken): WorkerToken { return [value.content, value.color ?? ''] } @@ -104,6 +136,9 @@ async function highlight(request: Extract) const instance = await highlighter if (!instance) throw new Error('Shiki worker not initialized') + // 主题按需加载(init 时只预加载了用户当前选择;切换主题后第一次 highlight 触发 lazy load) + await ensureTheme(instance, request.theme) + const requestedLanguage = request.language.toLowerCase() const language = plainLanguages.has(requestedLanguage) || findLangLoader(requestedLanguage) ? requestedLanguage : 'text' const isPlainText = plainLanguages.has(language) @@ -216,13 +251,26 @@ const themeLoaders: Record Promise> = { self.onmessage = (event: MessageEvent) => { const msg = event.data if (msg.type === 'init') { + // 用 bundledThemesInfo 解析 init 传入的主题 id(来自用户当前选择),用静态字面量 + // import 让 Vite 把它们打成独立 chunk。未知 id 回退到 github-dark-default。 + const resolvedThemeSpecs = msg.themes.map(t => { + const info = bundledThemesInfo.find(b => b.id === t) + return info ? info.import : themeLoaders['github-dark-default']! + }) highlighter ??= createHighlighterCore({ engine: createOnigurumaEngine(loadOnigWasm), - themes: msg.themes.map(t => themeLoaders[t]?.() ?? themeLoaders['github-dark-default']!()) as Parameters[0]['themes'], + themes: resolvedThemeSpecs as Parameters[0]['themes'], langs: [], }) void highlighter - .then(() => post({ type: 'ready' })) + .then(async instance => { + // 标记 init 预加载的主题为已加载,避免重复 ensureTheme + msg.themes.forEach(t => { + if (bundledThemesInfo.some(b => b.id === t)) loadedThemes.add(t) + }) + await instance + post({ type: 'ready' }) + }) .catch(error => post({ type: 'init-error', message: error instanceof Error ? error.message : String(error) })) return }