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"
}