@useavalon/avalon 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +54 -54
  2. package/mod.ts +302 -302
  3. package/package.json +49 -26
  4. package/src/build/integration-bundler-plugin.ts +116 -116
  5. package/src/build/integration-config.ts +168 -168
  6. package/src/build/integration-detection-plugin.ts +117 -117
  7. package/src/build/integration-resolver-plugin.ts +90 -90
  8. package/src/build/island-manifest.ts +269 -269
  9. package/src/build/island-types-generator.ts +476 -476
  10. package/src/build/mdx-island-transform.ts +464 -464
  11. package/src/build/mdx-plugin.ts +98 -98
  12. package/src/build/page-island-transform.ts +598 -598
  13. package/src/build/prop-extractors/index.ts +21 -21
  14. package/src/build/prop-extractors/lit.ts +140 -140
  15. package/src/build/prop-extractors/qwik.ts +16 -16
  16. package/src/build/prop-extractors/solid.ts +125 -125
  17. package/src/build/prop-extractors/svelte.ts +194 -194
  18. package/src/build/prop-extractors/vue.ts +111 -111
  19. package/src/build/sidecar-file-manager.ts +104 -104
  20. package/src/build/sidecar-renderer.ts +30 -30
  21. package/src/client/adapters/index.ts +21 -13
  22. package/src/client/components.ts +35 -35
  23. package/src/client/css-hmr-handler.ts +344 -344
  24. package/src/client/framework-adapter.ts +462 -462
  25. package/src/client/hmr-coordinator.ts +396 -396
  26. package/src/client/hmr-error-overlay.js +533 -533
  27. package/src/client/main.js +824 -816
  28. package/src/client/types/framework-runtime.d.ts +68 -68
  29. package/src/client/types/vite-hmr.d.ts +46 -46
  30. package/src/client/types/vite-virtual-modules.d.ts +70 -60
  31. package/src/components/Image.tsx +123 -123
  32. package/src/components/IslandErrorBoundary.tsx +145 -145
  33. package/src/components/LayoutDataErrorBoundary.tsx +141 -141
  34. package/src/components/LayoutErrorBoundary.tsx +127 -127
  35. package/src/components/PersistentIsland.tsx +52 -52
  36. package/src/components/StreamingErrorBoundary.tsx +233 -233
  37. package/src/components/StreamingLayout.tsx +538 -538
  38. package/src/core/components/component-analyzer.ts +192 -192
  39. package/src/core/components/component-detection.ts +508 -508
  40. package/src/core/components/enhanced-framework-detector.ts +500 -500
  41. package/src/core/components/framework-registry.ts +563 -563
  42. package/src/core/content/mdx-processor.ts +46 -46
  43. package/src/core/integrations/index.ts +19 -19
  44. package/src/core/integrations/loader.ts +125 -125
  45. package/src/core/integrations/registry.ts +175 -175
  46. package/src/core/islands/island-persistence.ts +325 -325
  47. package/src/core/islands/island-state-serializer.ts +258 -258
  48. package/src/core/islands/persistent-island-context.tsx +80 -80
  49. package/src/core/islands/use-persistent-state.ts +68 -68
  50. package/src/core/layout/enhanced-layout-resolver.ts +322 -322
  51. package/src/core/layout/layout-cache-manager.ts +485 -485
  52. package/src/core/layout/layout-composer.ts +357 -357
  53. package/src/core/layout/layout-data-loader.ts +516 -516
  54. package/src/core/layout/layout-discovery.ts +243 -243
  55. package/src/core/layout/layout-matcher.ts +299 -299
  56. package/src/core/layout/layout-types.ts +110 -110
  57. package/src/core/modules/framework-module-resolver.ts +273 -273
  58. package/src/islands/component-analysis.ts +213 -213
  59. package/src/islands/css-utils.ts +565 -565
  60. package/src/islands/discovery/index.ts +80 -80
  61. package/src/islands/discovery/registry.ts +340 -340
  62. package/src/islands/discovery/resolver.ts +477 -477
  63. package/src/islands/discovery/scanner.ts +386 -386
  64. package/src/islands/discovery/types.ts +117 -117
  65. package/src/islands/discovery/validator.ts +544 -544
  66. package/src/islands/discovery/watcher.ts +368 -368
  67. package/src/islands/framework-detection.ts +428 -428
  68. package/src/islands/integration-loader.ts +490 -490
  69. package/src/islands/island.tsx +565 -565
  70. package/src/islands/render-cache.ts +550 -550
  71. package/src/islands/types.ts +80 -80
  72. package/src/islands/universal-css-collector.ts +157 -157
  73. package/src/islands/universal-head-collector.ts +137 -137
  74. package/src/layout-system.d.ts +592 -592
  75. package/src/layout-system.ts +218 -218
  76. package/src/middleware/discovery.ts +268 -268
  77. package/src/middleware/executor.ts +315 -315
  78. package/src/middleware/index.ts +76 -76
  79. package/src/middleware/types.ts +99 -99
  80. package/src/nitro/build-config.ts +575 -575
  81. package/src/nitro/config.ts +483 -483
  82. package/src/nitro/error-handler.ts +636 -636
  83. package/src/nitro/index.ts +173 -173
  84. package/src/nitro/island-manifest.ts +584 -584
  85. package/src/nitro/middleware-adapter.ts +260 -260
  86. package/src/nitro/renderer.ts +1471 -1471
  87. package/src/nitro/route-discovery.ts +439 -439
  88. package/src/nitro/types.ts +321 -321
  89. package/src/render/collect-css.ts +198 -198
  90. package/src/render/error-pages.ts +79 -79
  91. package/src/render/isolated-ssr-renderer.ts +654 -654
  92. package/src/render/ssr.ts +1030 -1030
  93. package/src/schemas/api.ts +30 -30
  94. package/src/schemas/core.ts +64 -64
  95. package/src/schemas/index.ts +212 -212
  96. package/src/schemas/layout.ts +279 -279
  97. package/src/schemas/routing/index.ts +38 -38
  98. package/src/schemas/routing.ts +376 -376
  99. package/src/types/as-island.ts +20 -20
  100. package/src/types/image.d.ts +106 -106
  101. package/src/types/index.d.ts +22 -22
  102. package/src/types/island-jsx.d.ts +33 -33
  103. package/src/types/island-prop.d.ts +20 -20
  104. package/src/types/layout.ts +285 -285
  105. package/src/types/mdx.d.ts +6 -6
  106. package/src/types/routing.ts +555 -555
  107. package/src/types/types.ts +5 -5
  108. package/src/types/urlpattern.d.ts +49 -49
  109. package/src/types/vite-env.d.ts +11 -11
  110. package/src/utils/dev-logger.ts +299 -299
  111. package/src/utils/fs.ts +151 -151
  112. package/src/vite-plugin/auto-discover.ts +551 -551
  113. package/src/vite-plugin/config.ts +266 -266
  114. package/src/vite-plugin/errors.ts +127 -127
  115. package/src/vite-plugin/image-optimization.ts +156 -156
  116. package/src/vite-plugin/integration-activator.ts +126 -126
  117. package/src/vite-plugin/island-sidecar-plugin.ts +176 -176
  118. package/src/vite-plugin/module-discovery.ts +189 -189
  119. package/src/vite-plugin/nitro-integration.ts +1354 -1354
  120. package/src/vite-plugin/plugin.ts +403 -409
  121. package/src/vite-plugin/types.ts +327 -327
  122. package/src/vite-plugin/validation.ts +228 -228
  123. package/src/client/adapters/index.js +0 -12
  124. package/src/client/adapters/lit-adapter.js +0 -467
  125. package/src/client/adapters/lit-adapter.ts +0 -654
  126. package/src/client/adapters/preact-adapter.js +0 -223
  127. package/src/client/adapters/preact-adapter.ts +0 -331
  128. package/src/client/adapters/qwik-adapter.js +0 -259
  129. package/src/client/adapters/qwik-adapter.ts +0 -345
  130. package/src/client/adapters/react-adapter.js +0 -220
  131. package/src/client/adapters/react-adapter.ts +0 -353
  132. package/src/client/adapters/solid-adapter.js +0 -295
  133. package/src/client/adapters/solid-adapter.ts +0 -451
  134. package/src/client/adapters/svelte-adapter.js +0 -368
  135. package/src/client/adapters/svelte-adapter.ts +0 -524
  136. package/src/client/adapters/vue-adapter.js +0 -278
  137. package/src/client/adapters/vue-adapter.ts +0 -467
  138. package/src/client/components.js +0 -23
  139. package/src/client/css-hmr-handler.js +0 -263
  140. package/src/client/framework-adapter.js +0 -283
  141. package/src/client/hmr-coordinator.js +0 -274
@@ -1,464 +1,464 @@
1
- /**
2
- * MDX Island Transform Plugin
3
- *
4
- * Transforms island component usage in MDX files into renderIsland() calls.
5
- *
6
- * Supports two patterns:
7
- * 1. Imports from islands/ directories (legacy pattern)
8
- * 2. Components used with the `island` prop (preferred pattern)
9
- *
10
- * Problem: MDX files import island components directly and render them as raw JSX.
11
- * After MDX compilation, these become jsxDEV(ComponentName, ...) calls. Preact's
12
- * renderToString doesn't support async components, so we can't use async wrappers.
13
- *
14
- * Solution: Replaces the compiled JSX calls (_jsxDEV(Component, { island: ... }))
15
- * with await expressions that call renderIsland() directly. This allows proper
16
- * async SSR rendering of islands in MDX files.
17
- */
18
-
19
- import type { Plugin } from 'vite';
20
- import { dirname } from 'node:path';
21
-
22
- export interface MDXIslandTransformOptions {
23
- islandPathPatterns?: RegExp[];
24
- verbose?: boolean;
25
- }
26
-
27
- const DEFAULT_ISLAND_PATTERNS = [
28
- /['"]\.\.\/islands\//,
29
- /['"]\.\/islands\//,
30
- /['"]\.\.\/\.\.\/islands\//,
31
- /['"]\$islands\//,
32
- /['"]@\/islands\//,
33
- /['"]\/src\/islands\//,
34
- ];
35
-
36
- interface IslandImport {
37
- localName: string;
38
- importPath: string;
39
- islandPropUsage: boolean;
40
- }
41
-
42
- /**
43
- * Find all default imports in the code
44
- */
45
- function findAllDefaultImports(code: string): Map<string, string> {
46
- const imports = new Map<string, string>();
47
- const re = /import\s+([A-Z]\w*)\s+from\s+(['"][^'"]+['"])/g;
48
- let m;
49
- while ((m = re.exec(code)) !== null) {
50
- const localName = m[1];
51
- const importPath = m[2].slice(1, -1);
52
- imports.set(localName, importPath);
53
- }
54
- return imports;
55
- }
56
-
57
- /**
58
- * Find components used with the island prop in the code
59
- * Handles both raw JSX (<Component island={...}) and compiled JSX (_jsxDEV(Component, { island:)
60
- */
61
- function findIslandPropUsage(code: string): Set<string> {
62
- const components = new Set<string>();
63
-
64
- // Match raw JSX: <ComponentName ... island={...} or <ComponentName ... island ...
65
- const rawJsxRe = /<([A-Z]\w*)\s+[^>]*\bisland\s*[={]/g;
66
- let m;
67
- while ((m = rawJsxRe.exec(code)) !== null) {
68
- components.add(m[1]);
69
- }
70
-
71
- // Match compiled JSX: _jsxDEV(ComponentName, { island: or jsxDEV(ComponentName, { island:
72
- // Also handles jsx() and jsxs() variants
73
- const compiledJsxRe = /(?:_?jsxs?(?:DEV)?)\s*\(\s*([A-Z]\w*)\s*,\s*\{[^}]*\bisland\s*:/g;
74
- while ((m = compiledJsxRe.exec(code)) !== null) {
75
- components.add(m[1]);
76
- }
77
-
78
- return components;
79
- }
80
-
81
- function findIslandImports(code: string, patterns: RegExp[]): IslandImport[] {
82
- const imports: IslandImport[] = [];
83
- const allImports = findAllDefaultImports(code);
84
- const islandPropComponents = findIslandPropUsage(code);
85
-
86
- for (const [localName, importPath] of allImports) {
87
- const quotedPath = `"${importPath}"`;
88
- const isFromIslandsDir = patterns.some(p => p.test(quotedPath));
89
- const hasIslandProp = islandPropComponents.has(localName);
90
-
91
- if (isFromIslandsDir || hasIslandProp) {
92
- imports.push({ localName, importPath, islandPropUsage: hasIslandProp });
93
- }
94
- }
95
-
96
- return imports;
97
- }
98
-
99
- /**
100
- * Resolve an import path to an absolute src path for renderIsland
101
- */
102
- function resolveIslandSrc(importPath: string, fileId: string): string {
103
- // Already absolute
104
- if (importPath.startsWith('/src/')) return importPath;
105
- if (importPath.startsWith('/app/')) return importPath;
106
- if (importPath.startsWith('/')) return importPath;
107
-
108
- // Handle aliases - convert to absolute paths
109
- if (importPath.startsWith('@/')) {
110
- return '/app/' + importPath.slice(2);
111
- }
112
- if (importPath.startsWith('@shared/')) {
113
- return '/app/shared/' + importPath.slice(8);
114
- }
115
- if (importPath.startsWith('@modules/')) {
116
- return '/app/modules/' + importPath.slice(9);
117
- }
118
- if (importPath.startsWith('$components/')) {
119
- return '/src/components/' + importPath.slice(12);
120
- }
121
- if (importPath.startsWith('$islands/')) {
122
- return '/src/islands/' + importPath.slice(9);
123
- }
124
- if (importPath.startsWith('~/')) {
125
- return '/src/' + importPath.slice(2);
126
- }
127
-
128
- // Relative import - resolve relative to the file
129
- if (importPath.startsWith('.')) {
130
- const normalized = fileId.replaceAll('\\', '/');
131
-
132
- // Try to find /app/ or /src/ in the path
133
- let baseIndex = normalized.indexOf('/app/');
134
- if (baseIndex === -1) baseIndex = normalized.indexOf('/src/');
135
-
136
- if (baseIndex !== -1) {
137
- const fileDir = dirname(normalized.slice(baseIndex));
138
- // Simple path resolution
139
- const parts = fileDir.split('/');
140
- const importParts = importPath.split('/');
141
-
142
- for (const part of importParts) {
143
- if (part === '..') {
144
- parts.pop();
145
- } else if (part !== '.') {
146
- parts.push(part);
147
- }
148
- }
149
-
150
- return parts.join('/');
151
- }
152
- }
153
-
154
- // Fallback for islands directory pattern
155
- if (importPath.includes('/islands/')) {
156
- const parts = importPath.split('/');
157
- return '/src/islands/' + parts.at(-1);
158
- }
159
-
160
- // Fallback: return as-is with /src/ prefix
161
- return '/src/' + importPath.split('/').pop();
162
- }
163
-
164
- function detectFramework(src: string): string | undefined {
165
- if (src.endsWith('.vue')) return 'vue';
166
- if (src.endsWith('.svelte')) return 'svelte';
167
- if (src.includes('.solid.')) return 'solid';
168
- if (src.includes('.lit.')) return 'lit';
169
- if (src.includes('.qwik.')) return 'qwik';
170
- if (src.endsWith('.tsx') || src.endsWith('.jsx')) return 'preact';
171
- return undefined;
172
- }
173
-
174
- /**
175
- * Skip a brace-delimited expression `{...}`, handling nested braces and strings.
176
- * Returns the index after the closing brace.
177
- */
178
- function skipBracedExpression(code: string, openBraceIdx: number): number {
179
- let pos = openBraceIdx + 1;
180
- let depth = 1;
181
- while (pos < code.length && depth > 0) {
182
- const ch = code[pos];
183
- if (ch === '{') {
184
- depth++;
185
- pos++;
186
- } else if (ch === '}') {
187
- depth--;
188
- if (depth > 0) pos++;
189
- } else if (ch === "'" || ch === '"' || ch === '`') {
190
- pos = skipStringLiteral(code, pos);
191
- } else {
192
- pos++;
193
- }
194
- }
195
- return pos < code.length ? pos + 1 : pos;
196
- }
197
-
198
- /**
199
- * Skip a string literal (single, double, or backtick).
200
- * Returns index after closing quote.
201
- */
202
- function skipStringLiteral(code: string, pos: number): number {
203
- const quote = code[pos];
204
- pos++;
205
- while (pos < code.length && code[pos] !== quote) {
206
- if (code[pos] === '\\') pos++; // skip escaped char
207
- pos++;
208
- }
209
- return pos < code.length ? pos + 1 : pos;
210
- }
211
-
212
- /**
213
- * Find the end of a JSX function call: _jsxDEV(Component, {...}, ...)
214
- * Returns the index after the closing parenthesis.
215
- */
216
- function findJsxCallEnd(code: string, startIdx: number): number {
217
- let pos = startIdx;
218
- let depth = 0;
219
-
220
- // Find the opening parenthesis
221
- while (pos < code.length && code[pos] !== '(') pos++;
222
- if (pos >= code.length) return startIdx;
223
-
224
- // Now track parentheses depth
225
- while (pos < code.length) {
226
- const ch = code[pos];
227
- if (ch === '(') {
228
- depth++;
229
- pos++;
230
- } else if (ch === ')') {
231
- depth--;
232
- pos++;
233
- if (depth === 0) return pos;
234
- } else if (ch === '{') {
235
- pos = skipBracedExpression(code, pos);
236
- } else if (ch === "'" || ch === '"' || ch === '`') {
237
- pos = skipStringLiteral(code, pos);
238
- } else {
239
- pos++;
240
- }
241
- }
242
- return pos;
243
- }
244
-
245
- /**
246
- * Extract the island prop value from a JSX props object.
247
- * Given `{ island: { condition: 'on:interaction' }, other: 1 }`, returns `{ condition: 'on:interaction' }`
248
- */
249
- function extractIslandProp(propsStr: string): { islandValue: string; otherProps: string } | null {
250
- // Find `island:` or `island :` in the props
251
- const islandMatch = propsStr.match(/\bisland\s*:\s*/);
252
- if (!islandMatch) return null;
253
-
254
- const islandStart = islandMatch.index! + islandMatch[0].length;
255
-
256
- // The island value could be:
257
- // 1. An object literal: { condition: 'on:interaction' }
258
- // 2. A variable reference: islandOpts
259
- // 3. A more complex expression
260
-
261
- let islandEnd: number;
262
- if (propsStr[islandStart] === '{') {
263
- // Object literal - find matching closing brace
264
- islandEnd = skipBracedExpression(propsStr, islandStart);
265
- } else {
266
- // Find the next comma or closing brace
267
- let pos = islandStart;
268
- let depth = 0;
269
- while (pos < propsStr.length) {
270
- const ch = propsStr[pos];
271
- if (ch === '{' || ch === '[' || ch === '(') {
272
- depth++;
273
- pos++;
274
- } else if (ch === '}' || ch === ']' || ch === ')') {
275
- if (depth === 0) break;
276
- depth--;
277
- pos++;
278
- } else if (ch === ',' && depth === 0) {
279
- break;
280
- } else {
281
- pos++;
282
- }
283
- }
284
- islandEnd = pos;
285
- }
286
-
287
- const islandValue = propsStr.slice(islandStart, islandEnd).trim();
288
-
289
- // Build other props by removing the island prop
290
- const beforeIsland = propsStr.slice(0, islandMatch.index!).trim();
291
- const afterIsland = propsStr.slice(islandEnd).trim();
292
-
293
- // Clean up: remove trailing/leading commas
294
- let otherProps = beforeIsland;
295
- if (afterIsland.startsWith(',')) {
296
- otherProps += afterIsland.slice(1);
297
- } else {
298
- otherProps += afterIsland;
299
- }
300
-
301
- // Remove trailing comma before closing brace
302
- otherProps = otherProps.replace(/,\s*}$/, '}').replace(/{\s*,/, '{');
303
-
304
- return { islandValue, otherProps };
305
- }
306
-
307
- /**
308
- * Replace JSX calls for a component with renderIsland await expressions.
309
- * Transforms: _jsxDEV(Component, { island: {...}, prop: 1 }, ...)
310
- * Into: (await __AvalonRenderIsland({ src: "...", ...island, props: { prop: 1 } }))
311
- */
312
- function replaceJsxCalls(
313
- code: string,
314
- componentName: string,
315
- srcPath: string,
316
- framework: string | undefined,
317
- ): string {
318
- // Match patterns like: _jsxDEV(ComponentName, or jsxDEV(ComponentName, or jsx(ComponentName,
319
- const jsxCallPattern = new RegExp(
320
- '(_?jsxs?(?:DEV)?)\\s*\\(\\s*' + componentName + '\\s*,',
321
- 'g'
322
- );
323
-
324
- let result = '';
325
- let lastIndex = 0;
326
- let match;
327
-
328
- while ((match = jsxCallPattern.exec(code)) !== null) {
329
- const matchStart = match.index;
330
- const jsxFn = match[1];
331
-
332
- // Find the end of this JSX call
333
- const callEnd = findJsxCallEnd(code, matchStart);
334
- const fullCall = code.slice(matchStart, callEnd);
335
-
336
- // Check if this call has an island prop
337
- if (!fullCall.includes('island')) {
338
- // No island prop, keep as-is
339
- result += code.slice(lastIndex, callEnd);
340
- lastIndex = callEnd;
341
- continue;
342
- }
343
-
344
- // Extract the props object - it's the second argument
345
- // Pattern: jsxFn(Component, { props }, key, isStatic, source, self)
346
- const propsStart = fullCall.indexOf('{');
347
- if (propsStart === -1) {
348
- result += code.slice(lastIndex, callEnd);
349
- lastIndex = callEnd;
350
- continue;
351
- }
352
-
353
- const propsEnd = skipBracedExpression(fullCall, propsStart);
354
- const propsStr = fullCall.slice(propsStart, propsEnd);
355
-
356
- const extracted = extractIslandProp(propsStr);
357
- if (!extracted) {
358
- result += code.slice(lastIndex, callEnd);
359
- lastIndex = callEnd;
360
- continue;
361
- }
362
-
363
- const { islandValue, otherProps } = extracted;
364
- const fwArg = framework ? `framework: "${framework}",` : '';
365
-
366
- // Check if otherProps is empty (just `{}`)
367
- const hasOtherProps = otherProps.trim() !== '{}' && otherProps.trim() !== '';
368
- const propsArg = hasOtherProps ? `props: ${otherProps},` : '';
369
-
370
- // Build the renderIsland call
371
- // We spread the island value to get condition, ssr, etc.
372
- const renderCall = `(await __AvalonRenderIsland({ src: "${srcPath}", ${fwArg} ...(${islandValue}), ${propsArg} ssr: (${islandValue}).ssr !== undefined ? (${islandValue}).ssr : true }))`;
373
-
374
- result += code.slice(lastIndex, matchStart) + renderCall;
375
- lastIndex = callEnd;
376
- }
377
-
378
- result += code.slice(lastIndex);
379
- return result;
380
- }
381
-
382
- export function mdxIslandTransform(options: MDXIslandTransformOptions = {}): Plugin {
383
- const { islandPathPatterns = DEFAULT_ISLAND_PATTERNS, verbose = false } = options;
384
-
385
- return {
386
- name: 'avalon:mdx-island-transform',
387
- enforce: 'post',
388
-
389
- transform(code: string, id: string) {
390
- if (!id.endsWith('.mdx') && !id.includes('.mdx?')) {
391
- return null;
392
- }
393
-
394
- const islandImports = findIslandImports(code, islandPathPatterns);
395
- if (islandImports.length === 0) {
396
- return null;
397
- }
398
-
399
- if (verbose) {
400
- console.log('[mdx-island-transform] Found ' + islandImports.length + ' island import(s) in ' + id);
401
- for (const imp of islandImports) {
402
- console.log(' - ' + imp.localName + ' from ' + imp.importPath + (imp.islandPropUsage ? ' (island prop)' : ' (islands dir)'));
403
- }
404
- }
405
-
406
- let transformed = code;
407
-
408
- // Add the renderIsland import for async SSR
409
- const hasAvalonImport =
410
- transformed.includes('from "@useavalon/avalon"') || transformed.includes("from '@useavalon/avalon'");
411
-
412
- if (!hasAvalonImport) {
413
- const firstImport = /^(import\s.+?from\s+.+?\n)/m.exec(transformed);
414
- if (firstImport) {
415
- const pos = transformed.indexOf(firstImport[0]) + firstImport[0].length;
416
- const line = 'import { renderIsland as __AvalonRenderIsland } from "@useavalon/avalon";\n';
417
- transformed = transformed.slice(0, pos) + line + transformed.slice(pos);
418
- }
419
- }
420
-
421
- // Replace JSX calls with renderIsland await expressions
422
- for (const island of islandImports) {
423
- const srcPath = resolveIslandSrc(island.importPath, id);
424
- const fw = detectFramework(srcPath);
425
-
426
- transformed = replaceJsxCalls(transformed, island.localName, srcPath, fw);
427
- }
428
-
429
- // Comment out the original imports (keep for CSS graph but don't use the binding)
430
- for (const island of islandImports) {
431
- const importRe = new RegExp(
432
- `import\\s+${island.localName}\\s+from\\s+(['"][^'"]+['"])`,
433
- 'g'
434
- );
435
- transformed = transformed.replace(
436
- importRe,
437
- `import $1; // [mdx-island-transform] kept for CSS: ${island.localName}`
438
- );
439
- }
440
-
441
- // Make the MDX content function async so we can use await
442
- // Transform: function _createMdxContent(props) {
443
- // Into: async function _createMdxContent(props) {
444
- transformed = transformed.replace(
445
- /function\s+_createMdxContent\s*\(/g,
446
- 'async function _createMdxContent('
447
- );
448
-
449
- // Also make the default export async if it wraps _createMdxContent
450
- // Transform: export default function MDXContent(props = {}) {
451
- // Into: export default async function MDXContent(props = {}) {
452
- transformed = transformed.replace(
453
- /export\s+default\s+function\s+MDXContent\s*\(/g,
454
- 'export default async function MDXContent('
455
- );
456
-
457
- if (verbose) {
458
- console.log('[mdx-island-transform] Transformed ' + id);
459
- }
460
-
461
- return { code: transformed, map: null };
462
- },
463
- };
464
- }
1
+ /**
2
+ * MDX Island Transform Plugin
3
+ *
4
+ * Transforms island component usage in MDX files into renderIsland() calls.
5
+ *
6
+ * Supports two patterns:
7
+ * 1. Imports from islands/ directories (legacy pattern)
8
+ * 2. Components used with the `island` prop (preferred pattern)
9
+ *
10
+ * Problem: MDX files import island components directly and render them as raw JSX.
11
+ * After MDX compilation, these become jsxDEV(ComponentName, ...) calls. Preact's
12
+ * renderToString doesn't support async components, so we can't use async wrappers.
13
+ *
14
+ * Solution: Replaces the compiled JSX calls (_jsxDEV(Component, { island: ... }))
15
+ * with await expressions that call renderIsland() directly. This allows proper
16
+ * async SSR rendering of islands in MDX files.
17
+ */
18
+
19
+ import type { Plugin } from 'vite';
20
+ import { dirname } from 'node:path';
21
+
22
+ export interface MDXIslandTransformOptions {
23
+ islandPathPatterns?: RegExp[];
24
+ verbose?: boolean;
25
+ }
26
+
27
+ const DEFAULT_ISLAND_PATTERNS = [
28
+ /['"]\.\.\/islands\//,
29
+ /['"]\.\/islands\//,
30
+ /['"]\.\.\/\.\.\/islands\//,
31
+ /['"]\$islands\//,
32
+ /['"]@\/islands\//,
33
+ /['"]\/src\/islands\//,
34
+ ];
35
+
36
+ interface IslandImport {
37
+ localName: string;
38
+ importPath: string;
39
+ islandPropUsage: boolean;
40
+ }
41
+
42
+ /**
43
+ * Find all default imports in the code
44
+ */
45
+ function findAllDefaultImports(code: string): Map<string, string> {
46
+ const imports = new Map<string, string>();
47
+ const re = /import\s+([A-Z]\w*)\s+from\s+(['"][^'"]+['"])/g;
48
+ let m;
49
+ while ((m = re.exec(code)) !== null) {
50
+ const localName = m[1];
51
+ const importPath = m[2].slice(1, -1);
52
+ imports.set(localName, importPath);
53
+ }
54
+ return imports;
55
+ }
56
+
57
+ /**
58
+ * Find components used with the island prop in the code
59
+ * Handles both raw JSX (<Component island={...}) and compiled JSX (_jsxDEV(Component, { island:)
60
+ */
61
+ function findIslandPropUsage(code: string): Set<string> {
62
+ const components = new Set<string>();
63
+
64
+ // Match raw JSX: <ComponentName ... island={...} or <ComponentName ... island ...
65
+ const rawJsxRe = /<([A-Z]\w*)\s+[^>]*\bisland\s*[={]/g;
66
+ let m;
67
+ while ((m = rawJsxRe.exec(code)) !== null) {
68
+ components.add(m[1]);
69
+ }
70
+
71
+ // Match compiled JSX: _jsxDEV(ComponentName, { island: or jsxDEV(ComponentName, { island:
72
+ // Also handles jsx() and jsxs() variants
73
+ const compiledJsxRe = /(?:_?jsxs?(?:DEV)?)\s*\(\s*([A-Z]\w*)\s*,\s*\{[^}]*\bisland\s*:/g;
74
+ while ((m = compiledJsxRe.exec(code)) !== null) {
75
+ components.add(m[1]);
76
+ }
77
+
78
+ return components;
79
+ }
80
+
81
+ function findIslandImports(code: string, patterns: RegExp[]): IslandImport[] {
82
+ const imports: IslandImport[] = [];
83
+ const allImports = findAllDefaultImports(code);
84
+ const islandPropComponents = findIslandPropUsage(code);
85
+
86
+ for (const [localName, importPath] of allImports) {
87
+ const quotedPath = `"${importPath}"`;
88
+ const isFromIslandsDir = patterns.some(p => p.test(quotedPath));
89
+ const hasIslandProp = islandPropComponents.has(localName);
90
+
91
+ if (isFromIslandsDir || hasIslandProp) {
92
+ imports.push({ localName, importPath, islandPropUsage: hasIslandProp });
93
+ }
94
+ }
95
+
96
+ return imports;
97
+ }
98
+
99
+ /**
100
+ * Resolve an import path to an absolute src path for renderIsland
101
+ */
102
+ function resolveIslandSrc(importPath: string, fileId: string): string {
103
+ // Already absolute
104
+ if (importPath.startsWith('/src/')) return importPath;
105
+ if (importPath.startsWith('/app/')) return importPath;
106
+ if (importPath.startsWith('/')) return importPath;
107
+
108
+ // Handle aliases - convert to absolute paths
109
+ if (importPath.startsWith('@/')) {
110
+ return '/app/' + importPath.slice(2);
111
+ }
112
+ if (importPath.startsWith('@shared/')) {
113
+ return '/app/shared/' + importPath.slice(8);
114
+ }
115
+ if (importPath.startsWith('@modules/')) {
116
+ return '/app/modules/' + importPath.slice(9);
117
+ }
118
+ if (importPath.startsWith('$components/')) {
119
+ return '/src/components/' + importPath.slice(12);
120
+ }
121
+ if (importPath.startsWith('$islands/')) {
122
+ return '/src/islands/' + importPath.slice(9);
123
+ }
124
+ if (importPath.startsWith('~/')) {
125
+ return '/src/' + importPath.slice(2);
126
+ }
127
+
128
+ // Relative import - resolve relative to the file
129
+ if (importPath.startsWith('.')) {
130
+ const normalized = fileId.replaceAll('\\', '/');
131
+
132
+ // Try to find /app/ or /src/ in the path
133
+ let baseIndex = normalized.indexOf('/app/');
134
+ if (baseIndex === -1) baseIndex = normalized.indexOf('/src/');
135
+
136
+ if (baseIndex !== -1) {
137
+ const fileDir = dirname(normalized.slice(baseIndex));
138
+ // Simple path resolution
139
+ const parts = fileDir.split('/');
140
+ const importParts = importPath.split('/');
141
+
142
+ for (const part of importParts) {
143
+ if (part === '..') {
144
+ parts.pop();
145
+ } else if (part !== '.') {
146
+ parts.push(part);
147
+ }
148
+ }
149
+
150
+ return parts.join('/');
151
+ }
152
+ }
153
+
154
+ // Fallback for islands directory pattern
155
+ if (importPath.includes('/islands/')) {
156
+ const parts = importPath.split('/');
157
+ return '/src/islands/' + parts.at(-1);
158
+ }
159
+
160
+ // Fallback: return as-is with /src/ prefix
161
+ return '/src/' + importPath.split('/').pop();
162
+ }
163
+
164
+ function detectFramework(src: string): string | undefined {
165
+ if (src.endsWith('.vue')) return 'vue';
166
+ if (src.endsWith('.svelte')) return 'svelte';
167
+ if (src.includes('.solid.')) return 'solid';
168
+ if (src.includes('.lit.')) return 'lit';
169
+ if (src.includes('.qwik.')) return 'qwik';
170
+ if (src.endsWith('.tsx') || src.endsWith('.jsx')) return 'preact';
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * Skip a brace-delimited expression `{...}`, handling nested braces and strings.
176
+ * Returns the index after the closing brace.
177
+ */
178
+ function skipBracedExpression(code: string, openBraceIdx: number): number {
179
+ let pos = openBraceIdx + 1;
180
+ let depth = 1;
181
+ while (pos < code.length && depth > 0) {
182
+ const ch = code[pos];
183
+ if (ch === '{') {
184
+ depth++;
185
+ pos++;
186
+ } else if (ch === '}') {
187
+ depth--;
188
+ if (depth > 0) pos++;
189
+ } else if (ch === "'" || ch === '"' || ch === '`') {
190
+ pos = skipStringLiteral(code, pos);
191
+ } else {
192
+ pos++;
193
+ }
194
+ }
195
+ return pos < code.length ? pos + 1 : pos;
196
+ }
197
+
198
+ /**
199
+ * Skip a string literal (single, double, or backtick).
200
+ * Returns index after closing quote.
201
+ */
202
+ function skipStringLiteral(code: string, pos: number): number {
203
+ const quote = code[pos];
204
+ pos++;
205
+ while (pos < code.length && code[pos] !== quote) {
206
+ if (code[pos] === '\\') pos++; // skip escaped char
207
+ pos++;
208
+ }
209
+ return pos < code.length ? pos + 1 : pos;
210
+ }
211
+
212
+ /**
213
+ * Find the end of a JSX function call: _jsxDEV(Component, {...}, ...)
214
+ * Returns the index after the closing parenthesis.
215
+ */
216
+ function findJsxCallEnd(code: string, startIdx: number): number {
217
+ let pos = startIdx;
218
+ let depth = 0;
219
+
220
+ // Find the opening parenthesis
221
+ while (pos < code.length && code[pos] !== '(') pos++;
222
+ if (pos >= code.length) return startIdx;
223
+
224
+ // Now track parentheses depth
225
+ while (pos < code.length) {
226
+ const ch = code[pos];
227
+ if (ch === '(') {
228
+ depth++;
229
+ pos++;
230
+ } else if (ch === ')') {
231
+ depth--;
232
+ pos++;
233
+ if (depth === 0) return pos;
234
+ } else if (ch === '{') {
235
+ pos = skipBracedExpression(code, pos);
236
+ } else if (ch === "'" || ch === '"' || ch === '`') {
237
+ pos = skipStringLiteral(code, pos);
238
+ } else {
239
+ pos++;
240
+ }
241
+ }
242
+ return pos;
243
+ }
244
+
245
+ /**
246
+ * Extract the island prop value from a JSX props object.
247
+ * Given `{ island: { condition: 'on:interaction' }, other: 1 }`, returns `{ condition: 'on:interaction' }`
248
+ */
249
+ function extractIslandProp(propsStr: string): { islandValue: string; otherProps: string } | null {
250
+ // Find `island:` or `island :` in the props
251
+ const islandMatch = propsStr.match(/\bisland\s*:\s*/);
252
+ if (!islandMatch) return null;
253
+
254
+ const islandStart = islandMatch.index! + islandMatch[0].length;
255
+
256
+ // The island value could be:
257
+ // 1. An object literal: { condition: 'on:interaction' }
258
+ // 2. A variable reference: islandOpts
259
+ // 3. A more complex expression
260
+
261
+ let islandEnd: number;
262
+ if (propsStr[islandStart] === '{') {
263
+ // Object literal - find matching closing brace
264
+ islandEnd = skipBracedExpression(propsStr, islandStart);
265
+ } else {
266
+ // Find the next comma or closing brace
267
+ let pos = islandStart;
268
+ let depth = 0;
269
+ while (pos < propsStr.length) {
270
+ const ch = propsStr[pos];
271
+ if (ch === '{' || ch === '[' || ch === '(') {
272
+ depth++;
273
+ pos++;
274
+ } else if (ch === '}' || ch === ']' || ch === ')') {
275
+ if (depth === 0) break;
276
+ depth--;
277
+ pos++;
278
+ } else if (ch === ',' && depth === 0) {
279
+ break;
280
+ } else {
281
+ pos++;
282
+ }
283
+ }
284
+ islandEnd = pos;
285
+ }
286
+
287
+ const islandValue = propsStr.slice(islandStart, islandEnd).trim();
288
+
289
+ // Build other props by removing the island prop
290
+ const beforeIsland = propsStr.slice(0, islandMatch.index!).trim();
291
+ const afterIsland = propsStr.slice(islandEnd).trim();
292
+
293
+ // Clean up: remove trailing/leading commas
294
+ let otherProps = beforeIsland;
295
+ if (afterIsland.startsWith(',')) {
296
+ otherProps += afterIsland.slice(1);
297
+ } else {
298
+ otherProps += afterIsland;
299
+ }
300
+
301
+ // Remove trailing comma before closing brace
302
+ otherProps = otherProps.replace(/,\s*}$/, '}').replace(/{\s*,/, '{');
303
+
304
+ return { islandValue, otherProps };
305
+ }
306
+
307
+ /**
308
+ * Replace JSX calls for a component with renderIsland await expressions.
309
+ * Transforms: _jsxDEV(Component, { island: {...}, prop: 1 }, ...)
310
+ * Into: (await __AvalonRenderIsland({ src: "...", ...island, props: { prop: 1 } }))
311
+ */
312
+ function replaceJsxCalls(
313
+ code: string,
314
+ componentName: string,
315
+ srcPath: string,
316
+ framework: string | undefined,
317
+ ): string {
318
+ // Match patterns like: _jsxDEV(ComponentName, or jsxDEV(ComponentName, or jsx(ComponentName,
319
+ const jsxCallPattern = new RegExp(
320
+ '(_?jsxs?(?:DEV)?)\\s*\\(\\s*' + componentName + '\\s*,',
321
+ 'g'
322
+ );
323
+
324
+ let result = '';
325
+ let lastIndex = 0;
326
+ let match;
327
+
328
+ while ((match = jsxCallPattern.exec(code)) !== null) {
329
+ const matchStart = match.index;
330
+ const jsxFn = match[1];
331
+
332
+ // Find the end of this JSX call
333
+ const callEnd = findJsxCallEnd(code, matchStart);
334
+ const fullCall = code.slice(matchStart, callEnd);
335
+
336
+ // Check if this call has an island prop
337
+ if (!fullCall.includes('island')) {
338
+ // No island prop, keep as-is
339
+ result += code.slice(lastIndex, callEnd);
340
+ lastIndex = callEnd;
341
+ continue;
342
+ }
343
+
344
+ // Extract the props object - it's the second argument
345
+ // Pattern: jsxFn(Component, { props }, key, isStatic, source, self)
346
+ const propsStart = fullCall.indexOf('{');
347
+ if (propsStart === -1) {
348
+ result += code.slice(lastIndex, callEnd);
349
+ lastIndex = callEnd;
350
+ continue;
351
+ }
352
+
353
+ const propsEnd = skipBracedExpression(fullCall, propsStart);
354
+ const propsStr = fullCall.slice(propsStart, propsEnd);
355
+
356
+ const extracted = extractIslandProp(propsStr);
357
+ if (!extracted) {
358
+ result += code.slice(lastIndex, callEnd);
359
+ lastIndex = callEnd;
360
+ continue;
361
+ }
362
+
363
+ const { islandValue, otherProps } = extracted;
364
+ const fwArg = framework ? `framework: "${framework}",` : '';
365
+
366
+ // Check if otherProps is empty (just `{}`)
367
+ const hasOtherProps = otherProps.trim() !== '{}' && otherProps.trim() !== '';
368
+ const propsArg = hasOtherProps ? `props: ${otherProps},` : '';
369
+
370
+ // Build the renderIsland call
371
+ // We spread the island value to get condition, ssr, etc.
372
+ const renderCall = `(await __AvalonRenderIsland({ src: "${srcPath}", ${fwArg} ...(${islandValue}), ${propsArg} ssr: (${islandValue}).ssr !== undefined ? (${islandValue}).ssr : true }))`;
373
+
374
+ result += code.slice(lastIndex, matchStart) + renderCall;
375
+ lastIndex = callEnd;
376
+ }
377
+
378
+ result += code.slice(lastIndex);
379
+ return result;
380
+ }
381
+
382
+ export function mdxIslandTransform(options: MDXIslandTransformOptions = {}): Plugin {
383
+ const { islandPathPatterns = DEFAULT_ISLAND_PATTERNS, verbose = false } = options;
384
+
385
+ return {
386
+ name: 'avalon:mdx-island-transform',
387
+ enforce: 'post',
388
+
389
+ transform(code: string, id: string) {
390
+ if (!id.endsWith('.mdx') && !id.includes('.mdx?')) {
391
+ return null;
392
+ }
393
+
394
+ const islandImports = findIslandImports(code, islandPathPatterns);
395
+ if (islandImports.length === 0) {
396
+ return null;
397
+ }
398
+
399
+ if (verbose) {
400
+ console.log('[mdx-island-transform] Found ' + islandImports.length + ' island import(s) in ' + id);
401
+ for (const imp of islandImports) {
402
+ console.log(' - ' + imp.localName + ' from ' + imp.importPath + (imp.islandPropUsage ? ' (island prop)' : ' (islands dir)'));
403
+ }
404
+ }
405
+
406
+ let transformed = code;
407
+
408
+ // Add the renderIsland import for async SSR
409
+ const hasAvalonImport =
410
+ transformed.includes('from "@useavalon/avalon"') || transformed.includes("from '@useavalon/avalon'");
411
+
412
+ if (!hasAvalonImport) {
413
+ const firstImport = /^(import\s.+?from\s+.+?\n)/m.exec(transformed);
414
+ if (firstImport) {
415
+ const pos = transformed.indexOf(firstImport[0]) + firstImport[0].length;
416
+ const line = 'import { renderIsland as __AvalonRenderIsland } from "@useavalon/avalon";\n';
417
+ transformed = transformed.slice(0, pos) + line + transformed.slice(pos);
418
+ }
419
+ }
420
+
421
+ // Replace JSX calls with renderIsland await expressions
422
+ for (const island of islandImports) {
423
+ const srcPath = resolveIslandSrc(island.importPath, id);
424
+ const fw = detectFramework(srcPath);
425
+
426
+ transformed = replaceJsxCalls(transformed, island.localName, srcPath, fw);
427
+ }
428
+
429
+ // Comment out the original imports (keep for CSS graph but don't use the binding)
430
+ for (const island of islandImports) {
431
+ const importRe = new RegExp(
432
+ `import\\s+${island.localName}\\s+from\\s+(['"][^'"]+['"])`,
433
+ 'g'
434
+ );
435
+ transformed = transformed.replace(
436
+ importRe,
437
+ `import $1; // [mdx-island-transform] kept for CSS: ${island.localName}`
438
+ );
439
+ }
440
+
441
+ // Make the MDX content function async so we can use await
442
+ // Transform: function _createMdxContent(props) {
443
+ // Into: async function _createMdxContent(props) {
444
+ transformed = transformed.replace(
445
+ /function\s+_createMdxContent\s*\(/g,
446
+ 'async function _createMdxContent('
447
+ );
448
+
449
+ // Also make the default export async if it wraps _createMdxContent
450
+ // Transform: export default function MDXContent(props = {}) {
451
+ // Into: export default async function MDXContent(props = {}) {
452
+ transformed = transformed.replace(
453
+ /export\s+default\s+function\s+MDXContent\s*\(/g,
454
+ 'export default async function MDXContent('
455
+ );
456
+
457
+ if (verbose) {
458
+ console.log('[mdx-island-transform] Transformed ' + id);
459
+ }
460
+
461
+ return { code: transformed, map: null };
462
+ },
463
+ };
464
+ }