Skip to content

Commit 3880bab

Browse files
committed
Recursively load language files
1 parent b4d9e7c commit 3880bab

File tree

1 file changed

+89
-38
lines changed

1 file changed

+89
-38
lines changed

packages/streamdown/lib/code-block/cdn-loader.ts

Lines changed: 89 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,58 @@ const failedThemes = new Set<string>();
2929

3030
const jsonParseRegex = /JSON\.parse\(("(?:[^"\\]|\\.)*")\)/;
3131

32+
// Regex to extract relative imports like: import foo from './bar.mjs'
33+
const importRegex = /import\s+\w+\s+from\s+['"]\.\/([\w-]+)\.mjs['"]/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

Comments
 (0)