| @@ -1,9 +1,21 @@ |
| 1 | import { createHighlighterCore, type HighlighterCore, type LanguageInput } from "shiki/core"; | 1 | import { createHighlighterCoreSync, type HighlighterCore } from "shiki/core"; |
| 2 | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | 2 | import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; |
| 3 | import { useIsDark } from "../hooks/use-is-dark"; | 3 | import { useIsDark } from "../hooks/use-is-dark"; |
| 4 | | 4 | |
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 6 | // Supported languages — explicit imports to avoid bundling all grammars | 6 | // Eagerly loaded languages (sync — available immediately) |
| | 7 | // --------------------------------------------------------------------------- |
| | 8 | |
| | 9 | import langTypescript from "@shikijs/langs/typescript"; |
| | 10 | import langJavascript from "@shikijs/langs/javascript"; |
| | 11 | import langTsx from "@shikijs/langs/tsx"; |
| | 12 | import langJsx from "@shikijs/langs/jsx"; |
| | 13 | import langJson from "@shikijs/langs/json"; |
| | 14 | import githubDark from "@shikijs/themes/github-dark"; |
| | 15 | import githubLight from "@shikijs/themes/github-light"; |
| | 16 | |
| | 17 | // --------------------------------------------------------------------------- |
| | 18 | // Lazily loaded languages — imported on first use |
| 7 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 8 | | 20 | |
| 9 | const SUPPORTED_LANGS = [ | 21 | const SUPPORTED_LANGS = [ |
| @@ -61,13 +73,10 @@ const LANG_ALIASES: Record<string, SupportedLang> = { · 51 unmodified lines |
| 61 | yml: "yaml", | 73 | yml: "yaml", |
| 62 | }; | 74 | }; |
| 63 | | 75 | |
| 64 | const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = { | 76 | const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = { |
| 65 | json: () => import("@shikijs/langs/json"), | | |
| 66 | xml: () => import("@shikijs/langs/xml"), | 77 | xml: () => import("@shikijs/langs/xml"), |
| 67 | yaml: () => import("@shikijs/langs/yaml"), | 78 | yaml: () => import("@shikijs/langs/yaml"), |
| 68 | shellscript: () => import("@shikijs/langs/shellscript"), | 79 | shellscript: () => import("@shikijs/langs/shellscript"), |
| 69 | typescript: () => import("@shikijs/langs/typescript"), | | |
| 70 | javascript: () => import("@shikijs/langs/javascript"), | | |
| 71 | python: () => import("@shikijs/langs/python"), | 80 | python: () => import("@shikijs/langs/python"), |
| 72 | html: () => import("@shikijs/langs/html"), | 81 | html: () => import("@shikijs/langs/html"), |
| 73 | css: () => import("@shikijs/langs/css"), | 82 | css: () => import("@shikijs/langs/css"), |
| @@ -84,8 +93,6 @@ const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = { · 10 unmodified lines |
| 84 | c: () => import("@shikijs/langs/c"), | 93 | c: () => import("@shikijs/langs/c"), |
| 85 | cpp: () => import("@shikijs/langs/cpp"), | 94 | cpp: () => import("@shikijs/langs/cpp"), |
| 86 | csharp: () => import("@shikijs/langs/csharp"), | 95 | csharp: () => import("@shikijs/langs/csharp"), |
| 87 | tsx: () => import("@shikijs/langs/tsx"), | | |
| 88 | jsx: () => import("@shikijs/langs/jsx"), | | |
| 89 | toml: () => import("@shikijs/langs/toml"), | 96 | toml: () => import("@shikijs/langs/toml"), |
| 90 | dockerfile: () => import("@shikijs/langs/dockerfile"), | 97 | dockerfile: () => import("@shikijs/langs/dockerfile"), |
| 91 | diff: () => import("@shikijs/langs/diff"), | 98 | diff: () => import("@shikijs/langs/diff"), |
| @@ -121,8 +128,10 @@ export function useResolvedShikiTheme(theme?: ShikiThemeProp): SupportedTheme { · 29 unmodified lines |
| 121 | | 128 | |
| 122 | export function resolveLang(lang: string): SupportedLang | null { | 129 | export function resolveLang(lang: string): SupportedLang | null { |
| 123 | const l = lang.trim().toLowerCase(); | 130 | const l = lang.trim().toLowerCase(); |
| 124 | if (l in LANG_LOADERS) return l as SupportedLang; | 131 | if (supportedSet.has(l)) { |
| 125 | if (l in LANG_ALIASES) return LANG_ALIASES[l]!; | 132 | if (l in LANG_ALIASES) return LANG_ALIASES[l]!; |
| | 133 | return l as SupportedLang; |
| | 134 | } |
| 126 | return null; | 135 | return null; |
| 127 | } | 136 | } |
| 128 | | 137 | |
| @@ -131,22 +140,52 @@ export function isSupportedLang(lang: string): boolean { · 2 unmodified lines |
| 131 | } | 140 | } |
| 132 | | 141 | |
| 133 | // --------------------------------------------------------------------------- | 142 | // --------------------------------------------------------------------------- |
| 134 | // Shared singleton highlighter — lazy, created on first use | 143 | // Shared singleton highlighter — synchronous, created eagerly with core langs |
| 135 | // --------------------------------------------------------------------------- | 144 | // --------------------------------------------------------------------------- |
| 136 | | 145 | |
| 137 | const jsEngine = createJavaScriptRegexEngine({ forgiving: true }); | 146 | const highlighter: HighlighterCore = createHighlighterCoreSync({ |
| | 147 | themes: [githubDark, githubLight], |
| | 148 | langs: [langTypescript, langJavascript, langTsx, langJsx, langJson], |
| | 149 | engine: createJavaScriptRegexEngine({ forgiving: true }), |
| | 150 | }); |
| 138 | | 151 | |
| 139 | let _promise: Promise<HighlighterCore> | null = null; | 152 | export function getHighlighter(): HighlighterCore { |
| | 153 | return highlighter; |
| | 154 | } |
| 140 | | 155 | |
| 141 | export function getHighlighter(): Promise<HighlighterCore> { | 156 | /** |
| 142 | if (!_promise) { | 157 | * Ensure a language is loaded into the highlighter. Returns true if the |
| 143 | _promise = createHighlighterCore({ | 158 | * language is ready for synchronous use, false if it needs to be loaded |
| 144 | themes: [import("@shikijs/themes/github-dark"), import("@shikijs/themes/github-light")], | 159 | * asynchronously (in which case `onLoaded` will be called when ready). |
| 145 | langs: Object.values(LANG_LOADERS).map((loader) => loader()), | 160 | */ |
| 146 | engine: jsEngine, | 161 | const loadingLangs = new Set<string>(); |
| | 162 | |
| | 163 | export function ensureLang(lang: SupportedLang, onLoaded?: () => void): boolean { |
| | 164 | const loaded = highlighter.getLoadedLanguages(); |
| | 165 | if (loaded.includes(lang)) return true; |
| | 166 | |
| | 167 | const loader = LAZY_LANG_LOADERS[lang]; |
| | 168 | if (!loader) return true; // Not a lazy lang, must be already loaded |
| | 169 | |
| | 170 | if (!loadingLangs.has(lang)) { |
| | 171 | loadingLangs.add(lang); |
| | 172 | void loader().then((mod: any) => { |
| | 173 | const registration = mod.default ?? mod; |
| | 174 | highlighter.loadLanguageSync(registration); |
| | 175 | loadingLangs.delete(lang); |
| | 176 | onLoaded?.(); |
| | 177 | }); |
| | 178 | } else if (onLoaded) { |
| | 179 | const l = loader; |
| | 180 | void l().then((mod: any) => { |
| | 181 | const registration = mod.default ?? mod; |
| | 182 | if (!highlighter.getLoadedLanguages().includes(lang)) { |
| | 183 | highlighter.loadLanguageSync(registration); |
| | 184 | } |
| | 185 | onLoaded(); |
| 147 | }); | 186 | }); |
| 148 | } | 187 | } |
| 149 | return _promise; | 188 | return false; |
| 150 | } | 189 | } |
| 151 | | 190 | |
| 152 | // --------------------------------------------------------------------------- | 191 | // --------------------------------------------------------------------------- |
| @@ -156,7 +195,6 @@ export function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines |
| 156 | import type { CodeHighlighterPlugin, ThemeInput } from "streamdown"; | 195 | import type { CodeHighlighterPlugin, ThemeInput } from "streamdown"; |
| 157 | | 196 | |
| 158 | const tokensCache = new Map<string, unknown>(); | 197 | const tokensCache = new Map<string, unknown>(); |
| 159 | const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>(); | | |
| 160 | | 198 | |
| 161 | /** | 199 | /** |
| 162 | * Read the current system color-scheme preference synchronously. Used in | 200 | * Read the current system color-scheme preference synchronously. Used in |
| @@ -184,30 +222,24 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { · 21 unmodified lines |
| 184 | const cached = tokensCache.get(key); | 222 | const cached = tokensCache.get(key); |
| 185 | if (cached) return cached as never; | 223 | if (cached) return cached as never; |
| 186 | | 224 | |
| 187 | if (callback) { | 225 | const isReady = ensureLang(lang, () => { |
| 188 | if (!pendingCallbacks.has(key)) { | 226 | // Language just loaded — highlight and notify via callback |
| 189 | pendingCallbacks.set(key, new Set()); | | |
| 190 | } | | |
| 191 | pendingCallbacks.get(key)!.add(callback as (result: unknown) => void); | | |
| 192 | } | | |
| 193 | | | |
| 194 | void getHighlighter().then((highlighter) => { | | |
| 195 | if (tokensCache.has(key)) { | | |
| 196 | const result = tokensCache.get(key); | | |
| 197 | pendingCallbacks.get(key)?.forEach((cb) => cb(result)); | | |
| 198 | pendingCallbacks.delete(key); | | |
| 199 | return; | | |
| 200 | } | | |
| 201 | const result = highlighter.codeToTokens(options.code, { | 227 | const result = highlighter.codeToTokens(options.code, { |
| 202 | lang, | 228 | lang, |
| 203 | themes: { light: activeTheme, dark: activeTheme }, | 229 | themes: { light: activeTheme, dark: activeTheme }, |
| 204 | }); | 230 | }); |
| 205 | tokensCache.set(key, result); | 231 | tokensCache.set(key, result); |
| 206 | pendingCallbacks.get(key)?.forEach((cb) => cb(result)); | 232 | callback?.(result as never); |
| 207 | pendingCallbacks.delete(key); | | |
| 208 | }); | 233 | }); |
| 209 | | 234 | |
| 210 | return null; | 235 | if (!isReady) return null; |
| | 236 | |
| | 237 | const result = highlighter.codeToTokens(options.code, { |
| | 238 | lang, |
| | 239 | themes: { light: activeTheme, dark: activeTheme }, |
| | 240 | }); |
| | 241 | tokensCache.set(key, result); |
| | 242 | return result as never; |
| 211 | }, | 243 | }, |
| 212 | }; | 244 | }; |
| 213 | } | 245 | } |