History

sync shiki init with typescript, lazy-load other langs; fix sidebar border

Rhys Sullivan committed 7 months ago

Commit 6bc94ea1 parent 0d978a24 files · +94 −89Browse files

Unified · Split

4 files
  1. packages/react/src/components/code-block.tsx +8 −17
  2. packages/react/src/components/expandable-code-block.tsx +14 −32
  3. packages/react/src/components/tool-tree.tsx +2 −2
  4. packages/react/src/lib/shiki.ts +70 −38
packages/react/src/components/code-block.tsx+8−17
@@ -3,6 +3,7 @@ import { jsx, jsxs, Fragment } from "react/jsx-runtime"; · 2 unmodified lines
33 import { toJsxRuntime } from "hast-util-to-jsx-runtime";
44 import {
55 getHighlighter,
6+ ensureLang,
67 resolveLang,
78 useResolvedShikiTheme,
89 type ShikiThemeProp,
@@ -62,26 +63,16 @@ const CheckIcon = () => ( · 53 unmodified lines
6263 // ---------------------------------------------------------------------------
6364
6465 function useHighlighted(code: string, lang: string, theme: SupportedTheme): ReactNode | null {
65− const [highlighted, setHighlighted] = useState<ReactNode | null>(null);
66+ const [, setTick] = useState(0);
67+ const resolvedLang = (resolveLang(lang) ?? "json") as Parameters<typeof ensureLang>[0];
6668
67− useEffect(() => {
68− let cancelled = false;
69+ const isReady = ensureLang(resolvedLang, () => setTick((t) => t + 1));
6970
70− getHighlighter().then((highlighter) => {
71− if (cancelled) return;
71+ if (!isReady) return null;
7272
73− const hast = highlighter.codeToHast(code, { lang, theme });
74− const nodes = toJsxRuntime(hast, { jsx, jsxs, Fragment });
75−
76− if (!cancelled) setHighlighted(nodes);
77− });
78−
79− return () => {
80− cancelled = true;
81− };
82− }, [code, lang, theme]);
83−
84− return highlighted;
73+ const highlighter = getHighlighter();
74+ const hast = highlighter.codeToHast(code, { lang: resolvedLang, theme });
75+ return toJsxRuntime(hast, { jsx, jsxs, Fragment });
8576 }
8677
8778 // ---------------------------------------------------------------------------
packages/react/src/components/expandable-code-block.tsx+14−32
@@ -1,4 +1,4 @@
1−import { useCallback, useEffect, useMemo, useState, startTransition } from "react";
1+import { useCallback, useMemo, useState } from "react";
22 import {
33 getHighlighter,
44 useResolvedShikiTheme,
@@ -103,27 +103,13 @@ const CheckIcon = () => ( · 98 unmodified lines
103103 // Shiki tokenization hook — non-blocking
104104 // ---------------------------------------------------------------------------
105105
106−function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] | null {
107− const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);
108−
109− useEffect(() => {
110− let cancelled = false;
111− getHighlighter().then((highlighter) => {
112− if (cancelled) return;
113− const result = highlighter.codeToTokens(code, {
114− lang: "typescript",
115− theme,
116− });
117− if (!cancelled) {
118− startTransition(() => setTokens(result.tokens));
119− }
120− });
121− return () => {
122− cancelled = true;
123− };
124− }, [code, theme]);
125−
126− return tokens;
106+function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] {
107+ const highlighter = getHighlighter();
108+ const result = highlighter.codeToTokens(code, {
109+ lang: "typescript",
110+ theme,
111+ });
112+ return result.tokens;
127113 }
128114
129115 // ---------------------------------------------------------------------------
@@ -391,16 +377,12 @@ export function ExpandableCodeBlock(props: { · 261 unmodified lines
391377
392378 <pre className="overflow-auto p-3 font-mono text-[0.75rem] leading-6 !bg-transparent">
393379 <code>
394− {tokens ? (
395− <HighlightedCode
396− tokens={tokens}
397− clickableNames={clickableNames}
398− onToggle={handleToggle}
399− expanded={allExpanded}
400− />
401− ) : (
402− <span className="text-foreground/60">{displayCode}</span>
403− )}
380+ <HighlightedCode
381+ tokens={tokens}
382+ clickableNames={clickableNames}
383+ onToggle={handleToggle}
384+ expanded={allExpanded}
385+ />
404386 </code>
405387 </pre>
406388 </div>
packages/react/src/components/tool-tree.tsx+2−2
@@ -253,8 +253,8 @@ export function ToolTree(props: { · 252 unmodified lines
253253 }, [selectedToolId, rows]);
254254
255255 return (
256− <div className="flex h-full min-h-0 flex-col p-3">
257− <CardStack className="flex min-h-0 flex-1 flex-col">
256+ <div className="flex h-full min-h-0 flex-col">
257+ <CardStack className="flex min-h-0 flex-1 flex-col rounded-none border-0">
258258 {/* Search header */}
259259 <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
260260 <SearchIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
packages/react/src/lib/shiki.ts+70−38
@@ -1,9 +1,21 @@
1−import { createHighlighterCore, type HighlighterCore, type LanguageInput } from "shiki/core";
1+import { createHighlighterCoreSync, type HighlighterCore } from "shiki/core";
22 import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
33 import { useIsDark } from "../hooks/use-is-dark";
44
55 // ---------------------------------------------------------------------------
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
719 // ---------------------------------------------------------------------------
820
921 const SUPPORTED_LANGS = [
@@ -61,13 +73,10 @@ const LANG_ALIASES: Record<string, SupportedLang> = { · 51 unmodified lines
6173 yml: "yaml",
6274 };
6375
64−const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = {
65− json: () => import("@shikijs/langs/json"),
76+const LAZY_LANG_LOADERS: Partial<Record<SupportedLang, () => Promise<unknown>>> = {
6677 xml: () => import("@shikijs/langs/xml"),
6778 yaml: () => import("@shikijs/langs/yaml"),
6879 shellscript: () => import("@shikijs/langs/shellscript"),
69− typescript: () => import("@shikijs/langs/typescript"),
70− javascript: () => import("@shikijs/langs/javascript"),
7180 python: () => import("@shikijs/langs/python"),
7281 html: () => import("@shikijs/langs/html"),
7382 css: () => import("@shikijs/langs/css"),
@@ -84,8 +93,6 @@ const LANG_LOADERS: Record<SupportedLang, () => LanguageInput> = { · 10 unmodified lines
8493 c: () => import("@shikijs/langs/c"),
8594 cpp: () => import("@shikijs/langs/cpp"),
8695 csharp: () => import("@shikijs/langs/csharp"),
87− tsx: () => import("@shikijs/langs/tsx"),
88− jsx: () => import("@shikijs/langs/jsx"),
8996 toml: () => import("@shikijs/langs/toml"),
9097 dockerfile: () => import("@shikijs/langs/dockerfile"),
9198 diff: () => import("@shikijs/langs/diff"),
@@ -121,8 +128,10 @@ export function useResolvedShikiTheme(theme?: ShikiThemeProp): SupportedTheme { · 29 unmodified lines
121128
122129 export function resolveLang(lang: string): SupportedLang | null {
123130 const l = lang.trim().toLowerCase();
124− if (l in LANG_LOADERS) return l as SupportedLang;
125− if (l in LANG_ALIASES) return LANG_ALIASES[l]!;
131+ if (supportedSet.has(l)) {
132+ if (l in LANG_ALIASES) return LANG_ALIASES[l]!;
133+ return l as SupportedLang;
134+ }
126135 return null;
127136 }
128137
@@ -131,22 +140,52 @@ export function isSupportedLang(lang: string): boolean { · 2 unmodified lines
131140 }
132141
133142 // ---------------------------------------------------------------------------
134−// Shared singleton highlighter — lazy, created on first use
143+// Shared singleton highlighter — synchronous, created eagerly with core langs
135144 // ---------------------------------------------------------------------------
136145
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+});
138151
139−let _promise: Promise<HighlighterCore> | null = null;
152+export function getHighlighter(): HighlighterCore {
153+ return highlighter;
154+}
140155
141−export function getHighlighter(): Promise<HighlighterCore> {
142− if (!_promise) {
143− _promise = createHighlighterCore({
144− themes: [import("@shikijs/themes/github-dark"), import("@shikijs/themes/github-light")],
145− langs: Object.values(LANG_LOADERS).map((loader) => loader()),
146− engine: jsEngine,
156+/**
157+ * Ensure a language is loaded into the highlighter. Returns true if the
158+ * language is ready for synchronous use, false if it needs to be loaded
159+ * asynchronously (in which case `onLoaded` will be called when ready).
160+ */
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();
147186 });
148187 }
149− return _promise;
188+ return false;
150189 }
151190
152191 // ---------------------------------------------------------------------------
@@ -156,7 +195,6 @@ export function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines
156195 import type { CodeHighlighterPlugin, ThemeInput } from "streamdown";
157196
158197 const tokensCache = new Map<string, unknown>();
159−const pendingCallbacks = new Map<string, Set<(result: unknown) => void>>();
160198
161199 /**
162200 * Read the current system color-scheme preference synchronously. Used in
@@ -184,30 +222,24 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { · 21 unmodified lines
184222 const cached = tokensCache.get(key);
185223 if (cached) return cached as never;
186224
187− if (callback) {
188− if (!pendingCallbacks.has(key)) {
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− }
225+ const isReady = ensureLang(lang, () => {
226+ // Language just loaded — highlight and notify via callback
201227 const result = highlighter.codeToTokens(options.code, {
202228 lang,
203229 themes: { light: activeTheme, dark: activeTheme },
204230 });
205231 tokensCache.set(key, result);
206− pendingCallbacks.get(key)?.forEach((cb) => cb(result));
207− pendingCallbacks.delete(key);
232+ callback?.(result as never);
208233 });
209234
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;
211243 },
212244 };
213245 }