@@ -29,6 +29,58 @@ const failedThemes = new Set<string>();
2929
3030const jsonParseRegex = / J S O N \. p a r s e \( ( " (?: [ ^ " \\ ] | \\ .) * " ) \) / ;
3131
32+ // Regex to extract relative imports like: import foo from './bar.mjs'
33+ const importRegex = / i m p o r t \s + \w + \s + f r o m \s + [ ' " ] \. \/ ( [ \w - ] + ) \. m j s [ ' " ] / g;
34+
35+ // Track languages currently being loaded to prevent circular dependencies
36+ const loadingLanguages = new Set < string > ( ) ;
37+
38+ /**
39+ * Load a single language grammar file from CDN (without dependencies)
40+ */
41+ async function loadSingleLanguageFromCDN (
42+ language : string ,
43+ langsUrl : string ,
44+ timeout : number
45+ ) : Promise < { grammar : LanguageRegistration ; dependencies : string [ ] } | null > {
46+ const url = `${ langsUrl } /${ language } .mjs` ;
47+
48+ const controller = new AbortController ( ) ;
49+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeout ) ;
50+
51+ const response = await fetch ( url , {
52+ signal : controller . signal ,
53+ } ) ;
54+
55+ clearTimeout ( timeoutId ) ;
56+
57+ if ( ! response . ok ) {
58+ throw new Error ( `HTTP ${ response . status } : ${ response . statusText } ` ) ;
59+ }
60+
61+ const moduleText = await response . text ( ) ;
62+
63+ // Extract dependencies from import statements
64+ const dependencies : string [ ] = [ ] ;
65+ let match : RegExpExecArray | null ;
66+ while ( ( match = importRegex . exec ( moduleText ) ) !== null ) {
67+ dependencies . push ( match [ 1 ] ) ;
68+ }
69+ // Reset regex lastIndex for next use
70+ importRegex . lastIndex = 0 ;
71+
72+ // Extract the JSON string from the JSON.parse() call
73+ const jsonParseMatch = moduleText . match ( jsonParseRegex ) ;
74+ if ( ! jsonParseMatch ) {
75+ throw new Error ( "Could not find JSON.parse() in CDN response" ) ;
76+ }
77+
78+ const jsonString = JSON . parse ( jsonParseMatch [ 1 ] ) ;
79+ const grammar = JSON . parse ( jsonString ) as LanguageRegistration ;
80+
81+ return { grammar, dependencies } ;
82+ }
83+
3284/**
3385 * Load a language grammar from CDN
3486 * @param language - Language identifier (e.g., 'rust', 'ruby', 'elixir')
@@ -61,56 +113,53 @@ export async function loadLanguageFromCDN(
61113 return null ;
62114 }
63115
64- try {
65- const url = `${ langsUrl } /${ language } .mjs` ;
66-
67- // Create abort controller for timeout
68- const controller = new AbortController ( ) ;
69- const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeout ) ;
70-
71- const response = await fetch ( url , {
72- signal : controller . signal ,
73- } ) ;
116+ // Prevent circular dependencies
117+ if ( loadingLanguages . has ( cacheKey ) ) {
118+ return null ;
119+ }
74120
75- clearTimeout ( timeoutId ) ;
121+ try {
122+ loadingLanguages . add ( cacheKey ) ;
76123
77- if ( ! response . ok ) {
78- throw new Error ( `HTTP ${ response . status } : ${ response . statusText } ` ) ;
124+ const result = await loadSingleLanguageFromCDN ( language , langsUrl , timeout ) ;
125+ if ( ! result ) {
126+ throw new Error ( "Failed to load language" ) ;
79127 }
80128
81- // Get the module text
82- // Shiki language files have structure: const lang = Object.freeze(JSON.parse("{...}")); export default [lang];
83- const moduleText = await response . text ( ) ;
129+ const { grammar, dependencies } = result ;
130+ const allGrammars : LanguageRegistration [ ] = [ ] ;
84131
85- try {
86- // Extract the JSON string from the JSON.parse() call
87- // Need to handle nested quotes and escapes properly
88- const jsonParseMatch = moduleText . match ( jsonParseRegex ) ;
89- if ( ! jsonParseMatch ) {
90- throw new Error ( "Could not find JSON.parse() in CDN response" ) ;
132+ // Load dependencies first (they need to be registered before the main language)
133+ for ( const dep of dependencies ) {
134+ const depCacheKey = `${ langsUrl } /${ dep } ` ;
135+
136+ // Skip if already cached
137+ if ( cdnLanguageCache . has ( depCacheKey ) ) {
138+ const cached = cdnLanguageCache . get ( depCacheKey ) as LanguageRegistration [ ] ;
139+ allGrammars . push ( ...cached ) ;
140+ continue ;
91141 }
92142
93- // The matched string is already a valid JSON string literal
94- // We can parse it directly to get the unescaped version
95- const jsonString = JSON . parse ( jsonParseMatch [ 1 ] ) ;
143+ // Skip if already loading (circular dep) or failed
144+ if ( loadingLanguages . has ( depCacheKey ) || failedLanguages . has ( depCacheKey ) ) {
145+ continue ;
146+ }
96147
97- // Now parse the actual grammar JSON
98- const langObject = JSON . parse ( jsonString ) as LanguageRegistration ;
148+ // Recursively load dependency
149+ const depGrammars = await loadLanguageFromCDN ( dep , cdnBaseUrl , timeout ) ;
150+ if ( depGrammars ) {
151+ allGrammars . push ( ...depGrammars ) ;
152+ }
153+ }
99154
100- // Shiki expects an array, so wrap it
101- const grammar : LanguageRegistration [ ] = [ langObject ] ;
155+ // Add the main grammar last
156+ allGrammars . push ( grammar ) ;
102157
103- // Cache the grammar
104- cdnLanguageCache . set ( cacheKey , grammar ) ;
158+ // Cache the complete result (main grammar only, deps are cached separately)
159+ cdnLanguageCache . set ( cacheKey , [ grammar ] ) ;
105160
106- return grammar ;
107- } catch ( parseError ) {
108- throw new Error (
109- `Failed to parse language grammar: ${ parseError instanceof Error ? parseError . message : "Unknown error" } `
110- ) ;
111- }
161+ return allGrammars ;
112162 } catch ( error ) {
113- // Mark as failed to avoid repeated attempts
114163 failedLanguages . add ( cacheKey ) ;
115164
116165 const errorMessage =
@@ -121,6 +170,8 @@ export async function loadLanguageFromCDN(
121170 ) ;
122171
123172 return null ;
173+ } finally {
174+ loadingLanguages . delete ( cacheKey ) ;
124175 }
125176}
126177
0 commit comments