diff --git a/packages/plugin-app/src/components/Main.tsx b/packages/plugin-app/src/components/Main.tsx index 90877a2..9f7edf1 100644 --- a/packages/plugin-app/src/components/Main.tsx +++ b/packages/plugin-app/src/components/Main.tsx @@ -9,7 +9,7 @@ import * as shiki from 'shikiji' import { makeExposedPromise } from '../utils' import { env } from '../utils/env' import { loadBrowserFonts, useFonts } from '../utils/fonts' -import { useAsyncMemo, usePersistedState } from '../utils/hooks' +import { usePersistedState, useThemeHighlighter } from '../utils/hooks' import { Editor } from './Editor' import { Sidebar } from './Sidebar' @@ -45,16 +45,7 @@ const Main: React.FC = () => { }) const [fontSize, setFontSize] = usePersistedState({ initialValue: 13, storageKey: 'font-size' }) - const highlighter = useAsyncMemo( - () => - shiki - .getHighlighter({ themes: [themeName], langs: [language] }) - // NOTE we're returning the `themeName` and `language` here so we have a "transactional" guarantee that the - // highlighter is always in sync with the theme and language - .then((highlighter) => ({ highlighter, themeName, language })), - [themeName, language], - ) - const isHighlighterLoading = useMemo(() => highlighter === undefined, [highlighter]) + const { result: highlighter, isLoading: isHighlighterLoading, error: themeError } = useThemeHighlighter(themeName, language) const shikiTokens = useMemo( () => highlighter?.highlighter?.codeToThemedTokens(code, { theme: highlighter.themeName, lang: highlighter.language }), @@ -128,11 +119,36 @@ const Main: React.FC = () => { if (fontRes.isLoading || isHighlighterLoading) { return (
-
-
- +
+
+
+ +
+
Loading {isHighlighterLoading ? `theme: ${themeName}` : 'fonts'}...
-
Loading ...
+ {themeError && ( +
+ {themeError} +
+ )} +
+
+ ) + } + + // If there's a theme error but we're not loading, show an error state + if (themeError && !highlighter) { + return ( +
+
+
❌ Theme Loading Failed
+
{themeError}
+
) diff --git a/packages/plugin-app/src/utils/hooks.ts b/packages/plugin-app/src/utils/hooks.ts index c42962b..1238be8 100644 --- a/packages/plugin-app/src/utils/hooks.ts +++ b/packages/plugin-app/src/utils/hooks.ts @@ -1,6 +1,8 @@ import type { DependencyList } from 'react' import React from 'react' import { useEffect, useMemo, useState } from 'react' +import type { BuiltinLanguage, BuiltinTheme } from 'shikiji' +import * as shiki from 'shikiji' export const useAsyncMemo = (fn: () => Promise, deps: DependencyList): T | undefined => { const [val, setVal] = useState(undefined) @@ -10,7 +12,19 @@ export const useAsyncMemo = (fn: () => Promise, deps: DependencyList): T | setVal(undefined) } - fn().then((_) => setVal(_)) + // Create a timeout promise that rejects after 10 seconds + const timeoutPromise = new Promise((_, reject) => { + window.setTimeout(() => reject(new Error('Theme loading timeout')), 10000) + }) + + // Race between the actual function and timeout + Promise.race([fn(), timeoutPromise]) + .then((_) => setVal(_)) + .catch((error) => { + console.error('useAsyncMemo error:', error) + // Keep val as undefined so the loading state continues + // This will be handled by the fallback logic in Main.tsx + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) @@ -37,7 +51,7 @@ export const usePersistedState = ({ (_: T) => { setVal(_) clearTimeout(timeoutRef.current) - timeoutRef.current = setTimeout(() => { + timeoutRef.current = window.setTimeout(() => { const jsonVal = JSON.stringify(_) localStorage.setItem(storageKey, jsonVal) }, storageDebounceMs) @@ -56,3 +70,102 @@ export const usePersistedState = ({ return [val, updateValue] } + +export const useThemeHighlighter = (themeName: BuiltinTheme, language: BuiltinLanguage) => { + const [result, setResult] = useState<{ highlighter: shiki.Highlighter; themeName: BuiltinTheme; language: BuiltinLanguage } | undefined>(undefined) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + setIsLoading(true) + setError(null) + setResult(undefined) + + // Add abort controller to cancel requests when dependencies change + const abortController = new AbortController() + + const loadTheme = async (theme: BuiltinTheme): Promise<{ highlighter: shiki.Highlighter; themeName: BuiltinTheme; language: BuiltinLanguage }> => { + console.log(`Attempting to load theme: ${theme}`) + + // Create a timeout promise that rejects after 8 seconds + const timeoutPromise = new Promise((_, reject) => { + const timeoutId = window.setTimeout(() => reject(new Error(`Theme loading timeout for ${theme}`)), 8000) + + // Clean up timeout if aborted + abortController.signal.addEventListener('abort', () => { + clearTimeout(timeoutId) + reject(new Error('Theme loading aborted')) + }) + }) + + // Race between theme loading and timeout + const highlighter = await Promise.race([ + shiki.getHighlighter({ themes: [theme], langs: [language] }), + timeoutPromise + ]) + + // Check if request was aborted + if (abortController.signal.aborted) { + throw new Error('Theme loading aborted') + } + + return { highlighter, themeName: theme, language } + } + + const loadWithFallback = async () => { + try { + // Try loading the requested theme first + const result = await loadTheme(themeName) + + // Check if component was unmounted or dependencies changed + if (abortController.signal.aborted) return + + console.log(`Successfully loaded theme: ${themeName}`) + setResult(result) + setIsLoading(false) + } catch (error) { + // Check if component was unmounted or dependencies changed + if (abortController.signal.aborted) return + + console.error(`Failed to load theme ${themeName}:`, error) + + // If the requested theme is not github-dark, try fallback + if (themeName !== 'github-dark') { + try { + console.log('Falling back to github-dark theme') + const fallbackResult = await loadTheme('github-dark') + + // Check if component was unmounted or dependencies changed + if (abortController.signal.aborted) return + + console.log('Successfully loaded fallback theme: github-dark') + setResult(fallbackResult) + setError(`Failed to load ${themeName}, using github-dark as fallback`) + setIsLoading(false) + } catch (fallbackError) { + // Check if component was unmounted or dependencies changed + if (abortController.signal.aborted) return + + console.error('Failed to load fallback theme github-dark:', fallbackError) + setError(`Failed to load both ${themeName} and github-dark themes`) + setIsLoading(false) + } + } else { + // If github-dark itself failed, that's a serious problem + console.error('Failed to load github-dark theme:', error) + setError('Failed to load github-dark theme') + setIsLoading(false) + } + } + } + + loadWithFallback() + + // Cleanup function to abort any pending requests + return () => { + abortController.abort() + } + }, [themeName, language]) + + return { result, isLoading, error } +} diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 494084c..6f66fc6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -7,7 +7,8 @@ "@types/react-dom": "^18.2.7", "monaco-editor": "0.25.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "shikiji": "^0.6.8" }, "devDependencies": { "@types/react": "^18.2.21", diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index 96a3a8f..e00d4c6 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -90,8 +90,10 @@ const getFontStyles = (availableFonts: Font[], selectedFontFamily: string): Font .map((_) => _.fontName) .filter((_) => _.family === selectedFontFamily) .map((_) => _.style) - const bold = ['Bold', 'Semibold'].find((boldish) => fontStyles.some((_) => _ === boldish))! - const italic = ['Italic'].find((italicish) => fontStyles.some((_) => _ === italicish))! - const normal = ['Regular', 'Medium'].find((normalish) => fontStyles.some((_) => _ === normalish))! + + const bold = ['Bold', 'Semibold'].find((boldish) => fontStyles.some((_) => _ === boldish)) || '' + const italic = ['Italic'].find((italicish) => fontStyles.some((_) => _ === italicish)) || '' + const normal = ['Regular', 'Medium'].find((normalish) => fontStyles.some((_) => _ === normalish)) || fontStyles[0] || 'Regular' + return { bold, italic, normal } } diff --git a/packages/plugin/src/plugin/run.ts b/packages/plugin/src/plugin/run.ts index f95bf53..1eb4568 100644 --- a/packages/plugin/src/plugin/run.ts +++ b/packages/plugin/src/plugin/run.ts @@ -77,7 +77,7 @@ export const run = async ({ : tokenFontStyle === FontStyle.Italic ? fontStyles.italic : fontStyles.normal - if (fontStyle !== fontStyles.normal) { + if (fontStyle !== fontStyles.normal && fontStyle) { textNode.setRangeFontName(currentCharOffset, newOffset, { family: fontFamily, style: fontStyle, diff --git a/vercel.json b/vercel.json index 7ae9a3d..a51eeb9 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,7 @@ { "github": { "silent": true - } + }, + "installCommand": "pnpm install", + "buildCommand": "cd packages/plugin-app && pnpm run build" }